Skip to main content

max / goingson

Describe the task board, the second screen that could not be described Eleventh port. The day view took makeover-layout 0.24.0's Track; this one took 0.25.0's Region::Columns, and that is the whole of what it needed. tasks-kanban.js and task-board.js were refused in the same sentence as the timeline. Measured before the member was added: every card fact was already sayable. A title is a row's primary, the project and due date are meta, the priority and overdue markers are toned tokens, subtask progress is a Meter, opening a card is activate. A column is a heading, a count and a list. What nothing could say was that the three columns are peers -- Arrangement offered list-detail and sidebar-content, and a board described as either is a lie about the screen. Dragging never entered into it, which is the part worth writing down because it looks like a blocker. A drop's effect is "set status to Started": a discrete action with a discrete argument the vocabulary has always carried. So each card offers the two moves it is not already in, and the drag stays the host's affordance -- the same answer the day view's paint interaction got, reached from the other side. Three writes behind one route, matching the JS, because starting stamps a start time and completing runs the recurrence rule; neither is "set the status column". Moving back to Pending goes through UpdateTask, which replaces rather than patches, so every field is resent from the task just read. A test asserts tags and priority survive the move, since anything omitted from that struct is silently cleared. Moving a card to the column it is already in writes nothing. The JS bails on that too, and it matters more through a route: a repeated POST must not complete a task twice and mint a second recurrence. Takes quasi 0.7 and the makeover 0.25.0 cascade: layout 0.25.0, webview 0.43.0, build 0.36.0, tui 0.23.0. 559 tests, up from 550.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 16:36 UTC
Signed with PGP, not checked
Commit: 25d5f64625d9804fa8a19e5829b02de53fa6e287
Parent: b093372
6 files changed, +561 insertions, -27 deletions
M Cargo.lock +17 -17
@@ -3398,9 +3398,9 @@
3398 3398
3399 3399 [[package]]
3400 3400 name = "makeover-build"
3401 - version = "0.35.0"
3401 + version = "0.36.0"
3402 3402 source = "registry+https://github.com/rust-lang/crates.io-index"
3403 - checksum = "a97084502058507e10cad14d490963d74ad453e654d4fe3b3e83fbb6df5a64b8"
3403 + checksum = "fcfae38007d878c0e97774066bf253b77d32ae2c10e278bbf6c785b261bef69e"
3404 3404 dependencies = [
3405 3405 "makeover",
3406 3406 "makeover-geometry",
@@ -3415,15 +3415,15 @@
3415 3415
3416 3416 [[package]]
3417 3417 name = "makeover-layout"
3418 - version = "0.24.0"
3418 + version = "0.25.0"
3419 3419 source = "registry+https://github.com/rust-lang/crates.io-index"
3420 - checksum = "d7e8857e635b4b23c21bbea360a4b33eefb4c6ff252a4cf4b3564a55de55d19e"
3420 + checksum = "f7480bdecd1ccca99dbfd744327f5598d67dd9be3157c48928466b5e20d4aed3"
3421 3421
3422 3422 [[package]]
3423 3423 name = "makeover-touch"
3424 - version = "0.16.0"
3424 + version = "0.17.0"
3425 3425 source = "registry+https://github.com/rust-lang/crates.io-index"
3426 - checksum = "af562d05e17947bb8fe7f3ce45d8f9b803627ae14b8931ebf578e12ddae85bdc"
3426 + checksum = "bd1cac03a0e0b912a16f07044a79099c5bf8be3741403cd2b980d0aa4e57adac"
3427 3427 dependencies = [
3428 3428 "makeover-geometry",
3429 3429 "makeover-layout",
@@ -3431,9 +3431,9 @@
3431 3431
3432 3432 [[package]]
3433 3433 name = "makeover-tui"
3434 - version = "0.22.0"
3434 + version = "0.23.0"
3435 3435 source = "registry+https://github.com/rust-lang/crates.io-index"
3436 - checksum = "027e2be8f603b034a809ac2a7be7dc055ac7d3a864a3fa6f1a4418b8d976ad4f"
3436 + checksum = "d2c1f0eb73f3d89511a1a731b0f7e6705c39c7e06a7c944fcde1cbc7aebe5604"
3437 3437 dependencies = [
3438 3438 "makeover",
3439 3439 "makeover-layout",
@@ -3442,9 +3442,9 @@
3442 3442
3443 3443 [[package]]
3444 3444 name = "makeover-webview"
3445 - version = "0.42.0"
3445 + version = "0.43.0"
3446 3446 source = "registry+https://github.com/rust-lang/crates.io-index"
3447 - checksum = "6937a91744ab84f225f36656819a9db4b0a08d5adfb65a7f71f0b03b1281cd0e"
3447 + checksum = "1fc6f622ba5546e4ce0b8393addbb23c905dad5b1888ee7b588f04c696fecca4"
3448 3448 dependencies = [
3449 3449 "makeover-geometry",
3450 3450 "makeover-layout",
@@ -4651,7 +4651,7 @@
4651 4651
4652 4652 [[package]]
4653 4653 name = "quasi-http"
4654 - version = "0.6.0"
4654 + version = "0.7.0"
4655 4655 dependencies = [
4656 4656 "form_urlencoded",
4657 4657 "http",
@@ -4660,14 +4660,14 @@
4660 4660
4661 4661 [[package]]
4662 4662 name = "quasi-router"
4663 - version = "0.6.0"
4663 + version = "0.7.0"
4664 4664 dependencies = [
4665 4665 "makeover-layout",
4666 4666 ]
4667 4667
4668 4668 [[package]]
4669 4669 name = "quasi-tauri"
4670 - version = "0.6.0"
4670 + version = "0.7.0"
4671 4671 dependencies = [
4672 4672 "http",
4673 4673 "quasi-http",
@@ -4677,7 +4677,7 @@
4677 4677
4678 4678 [[package]]
4679 4679 name = "quasi-webview"
4680 - version = "0.6.0"
4680 + version = "0.7.0"
4681 4681 dependencies = [
4682 4682 "docengine",
4683 4683 "makeover-layout",
@@ -8375,15 +8375,15 @@
8375 8375
8376 8376 [[patch.unused]]
8377 8377 name = "quasi-axum"
8378 - version = "0.6.0"
8378 + version = "0.7.0"
8379 8379
8380 8380 [[patch.unused]]
8381 8381 name = "quasi-basics"
8382 - version = "0.6.0"
8382 + version = "0.7.0"
8383 8383
8384 8384 [[patch.unused]]
8385 8385 name = "quasi-immediate"
8386 - version = "0.6.0"
8386 + version = "0.7.0"
8387 8387
8388 8388 [[patch.unused]]
8389 8389 name = "quasi-store"
M Cargo.toml +1 -1
@@ -95,7 +95,7 @@
95 95 notify = "8.2"
96 96 notify-debouncer-mini = "0.7"
97 97 makeover = "2.5"
98 - makeover-tui = "0.22.0"
98 + makeover-tui = "0.23.0"
99 99 toml = "1.1"
100 100
101 101 # Enums
@@ -18,12 +18,12 @@
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css,
19 19 # and holds both drift checks this build.rs used to carry itself: touch density
20 20 # as of 0.17, breakpoints as of 0.18.
21 - makeover-build = "0.35.0"
21 + makeover-build = "0.36.0"
22 22 # The table CSS is generated here too: the columns are this app's, so the
23 23 # shared helper cannot know them, but the tracks and the narrowing rules come
24 24 # from the description rather than from hand-written nth-child cuts.
25 - makeover-webview = "0.42.0"
26 - makeover-layout = "0.24.0"
25 + makeover-webview = "0.43.0"
26 + makeover-layout = "0.25.0"
27 27 # Width. Direct rather than through makeover-webview, because the narrow table
28 28 # pass keys off SizeClass::Compact and a boundary reached transitively is a
29 29 # boundary nobody pinned.
@@ -45,10 +45,10 @@
45 45 # dependency's source even with the feature off -- so a public clone could not
46 46 # build at all against a URL it could not reach. quasi went public that day and
47 47 # the URL moved here with it.
48 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
49 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
50 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
51 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
48 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true }
49 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true }
50 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true }
51 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true }
52 52
53 53 # Tauri
54 54 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -112,8 +112,8 @@
112 112 # The forms emitter and the description it renders. Runtime deps, not build:
113 113 # form markup depends on the value, the error and the options at the moment a
114 114 # modal opens, so it cannot be materialised the way the stylesheet is.
115 - makeover-webview = "0.42.0"
116 - makeover-layout = "0.24.0"
115 + makeover-webview = "0.43.0"
116 + makeover-layout = "0.25.0"
117 117 # Browser opening
118 118 open = { workspace = true }
119 119
@@ -34,6 +34,7 @@
34 34
35 35 use crate::state::AppState;
36 36
37 + pub mod board;
37 38 pub mod contacts;
38 39 pub mod day_planning;
39 40 pub mod emails;
@@ -56,6 +57,7 @@
56 57 let router = monthly_review::routes(router);
57 58 let router = problems::routes(router);
58 59 let router = day_planning::routes(router);
60 + let router = board::routes(router);
59 61 emails::routes(router)
60 62 }
61 63
@@ -1,0 +1,312 @@
1 + //! The task board, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Eleventh port, and the second that needed the vocabulary to grow. The day
6 + //! view took `makeover-layout` 0.24.0's `Track`; this one took 0.25.0's
7 + //! [`RegionKind::Columns`].
8 + //!
9 + //! `frontend/js/tasks-kanban.js` (179) and `task-board.js` (109) were on the
10 + //! same "cannot be described" list as the timeline, in the same sentence. The
11 + //! measurement, taken before the member was added: every card fact was already
12 + //! sayable. A title is a row's primary, the project and the due date are meta,
13 + //! the blocked and unblocks markers are tokens, subtask progress is a `Meter`,
14 + //! opening a card is `activate` and the context menu is `menu`. A column is a
15 + //! heading, a count and a list.
16 + //!
17 + //! What nothing could say was that the three columns are **peers**. `Arrangement`
18 + //! offered list-detail and sidebar-content, and a board described as either is a
19 + //! lie about the screen. One member.
20 + //!
21 + //! # The shape
22 + //!
23 + //! - `GET /board` — the three columns.
24 + //! - `POST /board/{id}/status` — move a card, carrying `to`.
25 + //!
26 + //! # Dragging
27 + //!
28 + //! Never a blocker, and worth writing down because it looks like one. A drop's
29 + //! *effect* is `set status to Started`: a discrete action with a discrete
30 + //! argument, which the vocabulary has always been able to say. The drag is the
31 + //! affordance, it is presentation, and the host keeps it — the same answer the
32 + //! day view's paint interaction got, arrived at from the other side.
33 + //!
34 + //! So each card offers its two moves as acts. A webview may wire those to a
35 + //! drop target and a terminal may bind them to keys; both are honouring the
36 + //! same description.
37 + //!
38 + //! # `to` and not `status`
39 + //!
40 + //! The target state travels as `to` because `status` is what a column *is*.
41 + //! Second screen to hit that (quasicoherent `53936dd4`, closed), and the
42 + //! problems inbox was the first.
43 +
44 + // Handlers take their request by value because `quasi_router::Handler` is a
45 + // plain `fn(&S, Request)` pointer, so the signature is the router's.
46 + #![allow(clippy::needless_pass_by_value)]
47 +
48 + use goingson_core::{Priority, Task, TaskId, TaskStatus, UpdateTask};
49 + use makeover_layout::{Heading, Tone};
50 + use quasi_router::screen::{Act, Meter, Row, Tag};
51 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
52 +
53 + use crate::state::{AppState, DESKTOP_USER_ID};
54 +
55 + #[cfg(test)]
56 + mod tests;
57 +
58 + /// The three columns, in the order the board reads.
59 + ///
60 + /// `tasks-kanban.js`'s `COLUMNS`, and the same order. `Deleted` is not a column
61 + /// because a deleted task is not on the board; the JS drops it by only grouping
62 + /// the three it knows.
63 + const COLUMNS: [(TaskStatus, &str, &str); 3] = [
64 + (TaskStatus::Pending, "pending", "Pending"),
65 + (TaskStatus::Started, "started", "Started"),
66 + (TaskStatus::Completed, "done", "Completed"),
67 + ];
68 +
69 + /// The tone a priority wears. `tasks.rs`'s `priority_tone`, which is the same
70 + /// mapping and is not re-derived here on purpose.
71 + const fn priority_tone(priority: &Priority) -> Tone {
72 + match priority {
73 + Priority::High => Tone::Danger,
74 + Priority::Medium => Tone::Warning,
75 + Priority::Low => Tone::Neutral,
76 + }
77 + }
78 +
79 + /// The task a route was addressed at.
80 + fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
81 + let raw = request
82 + .captures
83 + .get("id")
84 + .ok_or_else(|| RouteError::not_found("no task id"))?;
85 + Ok(TaskId::from(
86 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
87 + ))
88 + }
89 +
90 + /// Where a card is being moved to.
91 + ///
92 + /// `to`, not `status`. Unparseable is a 404 rather than a silent no-op: a
93 + /// control naming a column that does not exist is a wiring mistake, and
94 + /// answering it with an unchanged board hides it.
95 + fn target(request: &quasi_router::Request) -> Result<TaskStatus, RouteError> {
96 + match request
97 + .payload
98 + .get("to")
99 + .or_else(|| request.carried.get("to"))
100 + {
101 + Some("Pending") => Ok(TaskStatus::Pending),
102 + Some("Started") => Ok(TaskStatus::Started),
103 + Some("Completed") => Ok(TaskStatus::Completed),
104 + _ => Err(RouteError::not_found("no such column")),
105 + }
106 + }
107 +
108 + /// One task as a card.
109 + ///
110 + /// Every part of this existed before the board did, which is the measurement
111 + /// the kanban refusal never took.
112 + fn card(task: &Task) -> Row {
113 + let mut row = Row::new(&task.title).token(
114 + Tag::badge(match task.priority {
115 + Priority::High => "High",
116 + Priority::Medium => "Medium",
117 + Priority::Low => "Low",
118 + })
119 + .tone(priority_tone(&task.priority)),
120 + );
121 +
122 + if let Some(project) = &task.project_name {
123 + row = row.meta(project);
124 + }
125 +
126 + // The due date, and whether it has passed. Overdue is a judgment the app
127 + // makes and the renderer cannot, so it travels as a tone rather than as a
128 + // class the way `kanban-card-due.overdue` does.
129 + if let Some(due) = task.due {
130 + let overdue = due < chrono::Utc::now() && task.status != TaskStatus::Completed;
131 + row = row.token(
132 + Tag::badge(
133 + due.with_timezone(&chrono::Local)
134 + .format("%b %-d")
135 + .to_string(),
136 + )
137 + .tone(if overdue { Tone::Danger } else { Tone::Neutral }),
138 + );
139 + }
140 +
141 + // Subtask progress. `Meter` says done-of-total; the JS says a percentage
142 + // width, which is the same fact already divided. Absent rather than empty
143 + // when a task has no subtasks, so a card does not carry a bar at zero.
144 + if task.subtask_count() > 0 {
145 + row = row.meter(Meter {
146 + done: u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX),
147 + total: u32::try_from(task.subtask_count()).unwrap_or(u32::MAX),
148 + tone: Tone::Success,
149 + label: None,
150 + });
151 + }
152 +
153 + // The moves this card offers, which is what a drop does. Its own column is
154 + // left out: dropping a card where it already is is the one case
155 + // `onDrop` bails on, and offering it would be an act that does nothing.
156 + for (status, _, label) in COLUMNS {
157 + if status != task.status {
158 + row = row.act(Act::new(
159 + format!("Move to {label}"),
160 + Action::post(format!("/board/{}/status", task.id)).with("to", label),
161 + ));
162 + }
163 + }
164 +
165 + row.activate(Action::get(format!("/tasks/{}", task.id)))
166 + }
167 +
168 + /// One column: its name, how many are in it, and the cards.
169 + fn column(state: &AppState, status: TaskStatus, id: &str, label: &str) -> Result<Slot, RouteError> {
170 + let tasks = state
171 + .tasks
172 + .list_all(DESKTOP_USER_ID)
173 + .map_err(|error| RouteError::internal(error.to_string()))?;
174 +
175 + let rows: Vec<Row> = tasks
176 + .iter()
177 + .filter(|t| t.status == status)
178 + .map(card)
179 + .collect();
180 +
181 + let mut slot = Slot::new(id, RegionKind::Pane).with(Node::Heading {
182 + level: Heading::Section,
183 + text: label.to_owned(),
184 + });
185 +
186 + // The count. A figure rather than the JS's bare span, because it is a
187 + // number with a caption and that is what a figure is.
188 + slot = slot.with(Node::Text {
189 + text: format!("{}", rows.len()),
190 + tone: Tone::Neutral,
191 + });
192 +
193 + Ok(if rows.is_empty() {
194 + slot.with(Node::empty("No tasks"))
195 + } else {
196 + slot.with(Node::list(rows))
197 + })
198 + }
199 +
200 + /// The board itself, as one region of peer columns.
201 + fn board_region(state: &AppState) -> Result<Slot, RouteError> {
202 + let mut board = Slot::new("board", RegionKind::Columns);
203 + for (status, id, label) in COLUMNS {
204 + board = board.with(Node::Region(column(state, status, id, label)?));
205 + }
206 + Ok(board)
207 + }
208 +
209 + /// The whole board.
210 + fn board(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
211 + Ok(Screen::list_detail("Board", false)
212 + .with(board_region(state)?)
213 + .into())
214 + }
215 +
216 + /// Move a card, and answer the board re-read.
217 + ///
218 + /// Re-read rather than patched, for the reason the problems triage is: the card
219 + /// leaves one column and joins another, and completing a recurring task creates
220 + /// its successor somewhere else on the board. Only the store knows what the
221 + /// board looks like afterwards.
222 + fn move_card(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
223 + let id = task_id(&request)?;
224 + let to = target(&request)?;
225 +
226 + let task = state
227 + .tasks
228 + .get_by_id(id, DESKTOP_USER_ID)
229 + .map_err(|error| RouteError::internal(error.to_string()))?
230 + .filter(|task| task.status != TaskStatus::Deleted)
231 + .ok_or_else(|| RouteError::not_found("no such task"))?;
232 +
233 + // Already there. The JS bails here too, and it matters more through a route
234 + // than through a drop: a repeated POST must not complete a task twice and
235 + // mint a second recurrence.
236 + if task.status == to {
237 + return Ok(Response::fragment(
238 + "board",
239 + Node::Region(board_region(state)?),
240 + ));
241 + }
242 +
243 + // Three different writes, which is what the JS does and is not incidental.
244 + // Starting stamps a start time and completing runs the recurrence rule, so
245 + // neither is "set the status column".
246 + let message = match to {
247 + TaskStatus::Started => {
248 + state
249 + .tasks
250 + .start(id, DESKTOP_USER_ID)
251 + .map_err(|error| RouteError::internal(error.to_string()))?;
252 + "Task started."
253 + }
254 + TaskStatus::Completed => {
255 + // The timer stops with it, matching `complete_task`. A card
256 + // completed with a timer running would otherwise keep accruing.
257 + let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID);
258 + state
259 + .tasks
260 + .complete(id, DESKTOP_USER_ID)
261 + .map_err(|error| RouteError::internal(error.to_string()))?
262 + .ok_or_else(|| RouteError::not_found("no such task"))?;
263 + "Task completed."
264 + }
265 + TaskStatus::Pending => {
266 + // `UpdateTask` replaces rather than patches, which is why the JS
267 + // resends every field on this path and why this does too. Built
268 + // from the task just read, so the only thing that changes is the
269 + // status; anything left out would be cleared, and moving a card
270 + // left is not a reason to lose its tags.
271 + state
272 + .tasks
273 + .update(
274 + id,
275 + DESKTOP_USER_ID,
276 + UpdateTask {
277 + project_id: task.project_id,
278 + milestone_id: task.milestone_id,
279 + contact_id: task.contact_id,
280 + title: task.title.clone(),
281 + description: task.description.clone(),
282 + status: TaskStatus::Pending,
283 + priority: task.priority,
284 + due: task.due,
285 + tags: task.tags.clone(),
286 + recurrence: task.recurrence,
287 + recurrence_rule: task.recurrence_rule.clone(),
288 + urgency: task.urgency,
289 + scheduled_start: task.scheduled_start,
290 + scheduled_duration: task.scheduled_duration,
291 + estimated_minutes: task.estimated_minutes,
292 + },
293 + )
294 + .map_err(|error| RouteError::internal(error.to_string()))?
295 + .ok_or_else(|| RouteError::not_found("no such task"))?;
296 + "Task moved to Pending."
297 + }
298 + TaskStatus::Deleted => return Err(RouteError::not_found("no such column")),
299 + };
300 +
301 + Ok(
302 + Response::fragment("board", Node::Region(board_region(state)?))
303 + .toast(Tone::Success, message),
304 + )
305 + }
306 +
307 + /// This screen's routes.
308 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
309 + router
310 + .get("/board", board)
311 + .post("/board/{id}/status", move_card)
312 + }
@@ -1,0 +1,220 @@
1 + //! The board, driven through the router against a real database.
2 + //!
3 + //! The assertions worth reading are the ones about *peers*: that the three
4 + //! columns are one region of equals rather than a list and a detail, which is
5 + //! the single member `makeover-layout` 0.25.0 added for this screen.
6 +
7 + use std::sync::Arc;
8 +
9 + use goingson_core::{NewTask, Priority, TaskStatus};
10 + use quasi_http::Serves as _;
11 + use quasi_router::{Outcome, Params, Request, Response};
12 +
13 + use super::super::router;
14 + use crate::state::{AppState, DESKTOP_USER_ID};
15 +
16 + async fn state() -> Arc<AppState> {
17 + let (state, _) = crate::test_utils::setup_test_state().await;
18 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
19 + state
20 + .db
21 + .conn()
22 + .unwrap()
23 + .execute(
24 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
25 + VALUES (?, ?, ?, ?, ?)",
26 + rusqlite::params![
27 + DESKTOP_USER_ID.to_string(),
28 + "desktop@localhost",
29 + "x",
30 + "Desktop User",
31 + &now,
32 + ],
33 + )
34 + .unwrap();
35 + state
36 + }
37 +
38 + /// A pending task, which is where every card starts.
39 + fn task(state: &AppState, title: &str) -> goingson_core::TaskId {
40 + state
41 + .tasks
42 + .create(
43 + DESKTOP_USER_ID,
44 + NewTask::builder(title).priority(Priority::Medium).build(),
45 + )
46 + .unwrap()
47 + .id
48 + }
49 +
50 + fn get(state: &AppState, path: &str) -> Response {
51 + router()
52 + .handle(state, Request::get(path).carrying(Params::new()))
53 + .expect("the route answers")
54 + }
55 +
56 + fn html(response: Response) -> String {
57 + match response.outcome {
58 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
59 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
60 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
61 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
62 + }
63 + }
64 +
65 + fn board(state: &AppState) -> String {
66 + html(get(state, "/board"))
67 + }
68 +
69 + fn move_to(state: &AppState, id: goingson_core::TaskId, to: &str) -> Response {
70 + router()
71 + .handle(
72 + state,
73 + Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", to)),
74 + )
75 + .expect("the route answers")
76 + }
77 +
78 + #[tokio::test]
79 + async fn the_three_columns_are_peers_and_not_a_list_and_a_detail() {
80 + let state = state().await;
81 + let markup = board(&state);
82 +
83 + // The whole point of the member. Before 0.25.0 this screen could only have
84 + // been described as list-detail, which says the left column chooses what
85 + // the right shows -- a lie about a board.
86 + assert!(markup.contains("columns"), "{markup}");
87 + assert!(!markup.contains("split"), "{markup}");
88 + for label in ["Pending", "Started", "Completed"] {
89 + assert!(markup.contains(label), "{markup}");
90 + }
91 + }
92 +
93 + #[tokio::test]
94 + async fn a_card_carries_the_facts_the_js_card_carried() {
95 + let state = state().await;
96 + task(&state, "Write the thing");
97 +
98 + let markup = board(&state);
99 +
100 + assert!(markup.contains("Write the thing"), "{markup}");
101 + assert!(markup.contains("Medium"), "{markup}");
102 + }
103 +
104 + #[tokio::test]
105 + async fn a_card_offers_the_moves_it_is_not_already_in() {
106 + let state = state().await;
107 + let id = task(&state, "Movable");
108 +
109 + let markup = board(&state);
110 +
111 + // Two moves, not three. Dropping a card where it already is is the case
112 + // `onDrop` bails on, so offering it would be an act that does nothing.
113 + assert!(markup.contains("Move to Started"), "{markup}");
114 + assert!(markup.contains("Move to Completed"), "{markup}");
115 + assert!(!markup.contains("Move to Pending"), "{markup}");
116 +
117 + // And the move is an ordinary posted action, which is why the drag never
118 + // needed describing.
119 + assert!(markup.contains(&format!("/board/{id}/status")), "{markup}");
120 + }
121 +
122 + #[tokio::test]
123 + async fn moving_a_card_moves_it_and_answers_the_board() {
124 + let state = state().await;
125 + let id = task(&state, "Movable");
126 +
127 + let response = move_to(&state, id, "Started");
128 + let Outcome::Fragment { region, .. } = &response.outcome else {
129 + panic!("a move replaces the board, not the screen");
130 + };
131 + assert_eq!(region, "board");
132 +
133 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
134 + assert_eq!(after.status, TaskStatus::Started);
135 +
136 + // Now it offers the way back and no longer offers the way it came.
137 + let markup = html(response);
138 + assert!(markup.contains("Move to Pending"), "{markup}");
139 + assert!(!markup.contains("Move to Started"), "{markup}");
140 + }
141 +
142 + #[tokio::test]
143 + async fn moving_a_card_to_the_column_it_is_in_writes_nothing() {
144 + let state = state().await;
145 + let id = task(&state, "Stationary");
146 +
147 + // Matters more through a route than through a drop. A repeated POST must
148 + // not complete a task twice and mint a second recurrence, and the JS's
149 + // `task.status === newStatus` bail is not reachable from a URL.
150 + let response = move_to(&state, id, "Pending");
151 + assert!(matches!(response.outcome, Outcome::Fragment { .. }));
152 +
153 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
154 + assert_eq!(after.status, TaskStatus::Pending);
155 + // No toast, because nothing happened.
156 + assert!(response.notice.is_none(), "{:?}", response.notice);
157 + }
158 +
159 + #[tokio::test]
160 + async fn moving_back_to_pending_keeps_everything_else_about_the_task() {
161 + let state = state().await;
162 + let id = state
163 + .tasks
164 + .create(
165 + DESKTOP_USER_ID,
166 + NewTask::builder("Tagged")
167 + .priority(Priority::High)
168 + .tags(vec!["alpha".into(), "beta".into()])
169 + .build(),
170 + )
171 + .unwrap()
172 + .id;
173 + move_to(&state, id, "Started");
174 +
175 + move_to(&state, id, "Pending");
176 +
177 + // `UpdateTask` replaces rather than patches, so the Pending path resends
178 + // every field. Anything left out of that struct is silently cleared, and
179 + // moving a card left is not a reason to lose its tags.
180 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
181 + assert_eq!(after.status, TaskStatus::Pending);
182 + assert_eq!(after.priority, Priority::High);
183 + assert_eq!(after.tags, vec!["alpha".to_string(), "beta".to_string()]);
184 + assert_eq!(after.title, "Tagged");
185 + }
186 +
187 + #[tokio::test]
188 + async fn a_column_that_does_not_exist_is_a_404() {
189 + let state = state().await;
190 + let id = task(&state, "Movable");
191 +
192 + // A control naming a column that is not there is a wiring mistake, and
193 + // answering it with an unchanged board hides it.
194 + let answered = router().handle(
195 + &state,
196 + Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", "Archived")),
197 + );
198 + assert!(answered.is_err(), "an unknown column should not resolve");
199 + }
200 +
201 + #[tokio::test]
202 + async fn a_cards_title_cannot_become_markup() {
203 + let state = state().await;
204 + task(&state, "<script>alert('x')</script>");
205 +
206 + let markup = board(&state);
207 +
208 + assert!(!markup.contains("<script>"), "{markup}");
209 + assert!(markup.contains("&lt;script&gt;"), "{markup}");
210 + }
211 +
212 + #[tokio::test]
213 + async fn an_empty_column_says_so_rather_than_drawing_nothing() {
214 + let state = state().await;
215 + let markup = board(&state);
216 +
217 + // Three empty columns on a fresh board. An empty column that draws nothing
218 + // reads as a broken board rather than an empty one.
219 + assert_eq!(markup.matches("No tasks").count(), 3, "{markup}");
220 + }