Skip to main content

max / goingson

Deleting a task redirects and says so, instead of a tombstone The delete handler answered with a screen saying the task was gone, addressed by the id of the thing that no longer existed. That was the honest description of a router whose every answer was content, and it was finding 80afd652. quasi now has Response::goto and a notice beside it, so this is a redirect to the list carrying "Task deleted". The notice is not decoration: the list it lands on looks the same whether a task was deleted or the user navigated. The test that asserted the tombstone asserts the redirect and the notice. Every workaround here is asserted, so closing a finding is a test that changes rather than an assertion that is dropped. The two html() test helpers grew a Goto arm rather than a wildcard, which is Outcome not being non_exhaustive doing its job: a fallback returning empty markup would have read as a screen that rendered nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:36 UTC
Signed with PGP, not checked
Commit: f44e64fe10a705b042ea17f9886383866fee7c53
Parent: c655ee1
5 files changed, +100 insertions, -64 deletions
@@ -532,19 +532,25 @@
532 532
533 533 /// Delete the task.
534 534 ///
535 - /// # The sixth finding
535 + /// # The sixth finding, closed 2026-08-09
536 536 ///
537 - /// **Nothing describes going somewhere else.** Every answer replaces a region
537 + /// **Nothing described going somewhere else.** Every answer replaced a region
538 538 /// or the screen in place. Deleting the thing a screen is about is the first
539 - /// case where the right answer is a *different* screen — the JS closes the
540 - /// drawer and leaves the list underneath — and the description has no way to
541 - /// say "show that address now". The projects and contacts writes never met this
539 + /// case where the right answer is a *different* address — the JS closes the
540 + /// drawer and leaves the list underneath — and the description had no way to
541 + /// say "show that address now". The projects and contacts writes never met it,
542 542 /// because their answer was always the same screen re-read.
543 543 ///
544 - /// So this answers with a screen saying the task is gone, which is honest and
545 - /// is not what the app should do. Filed as quasicoherent `80afd652` rather than
546 - /// on makeover-layout, because it is a fact about the response and not about
547 - /// what is on screen.
544 + /// This answered with a screen saying the task was gone, addressed by the id of
545 + /// the thing that no longer existed. quasicoherent `80afd652` closed that:
546 + /// [`Response`] is a struct now, carrying an [`Outcome`](quasi_router::Outcome)
547 + /// that can be a redirect, and a notice beside it.
548 + ///
549 + /// The notice is the second half and was its own finding (`a92ecb1e`). A
550 + /// redirect alone cannot say what happened, because the list it lands on looks
551 + /// the same whether a task was deleted or the user simply navigated. The two
552 + /// were filed apart and had to land together: one enum member could not be
553 + /// both.
548 554 fn remove(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
549 555 let id = task_id(&params)?;
550 556 let deleted = state
@@ -554,12 +560,11 @@
554 560 if !deleted {
555 561 return Err(RouteError::not_found("no such task"));
556 562 }
557 - Ok(Screen::list_detail("Task", false)
558 - .with(Slot::new("task-band", RegionKind::Band).with(Node::page("Task deleted")))
559 - .with(Slot::new("task-overview", RegionKind::Pane).with(Node::text(
560 - "The task is gone. Nothing here can say where to go next; see the note on this handler.",
561 - )))
562 - .into())
563 + // Was a tombstone screen until 2026-08-09: a page saying the task was gone,
564 + // on a screen addressed by the id of the thing that no longer exists. That
565 + // was finding `80afd652`, and it was the honest description of a router
566 + // whose every answer was content.
567 + Ok(Response::goto(Action::get("/tasks")).toast(makeover_layout::Tone::Success, "Task deleted"))
563 568 }
564 569
565 570 /// Add a subtask.
@@ -9,6 +9,7 @@
9 9
10 10 use goingson_core::{NewContact, NewContactEmail, NewSocialHandle};
11 11 use quasi_http::Render as _;
12 + use quasi_router::Outcome;
12 13 use quasi_router::{Method, Params, Response};
13 14
14 15 use super::super::router;
@@ -72,14 +73,14 @@
72 73 }
73 74
74 75 fn screen_html(response: Response) -> String {
75 - let Response::Screen(screen) = response else {
76 + let Outcome::Screen(screen) = (response).outcome else {
76 77 panic!("the route answers with a screen");
77 78 };
78 79 quasi_webview::Webview::new().screen(&screen)
79 80 }
80 81
81 82 fn fragment_html(response: Response) -> String {
82 - let Response::Fragment { node, .. } = response else {
83 + let Outcome::Fragment { node, .. } = (response).outcome else {
83 84 panic!("the route answers with a fragment");
84 85 };
85 86 quasi_webview::Webview::new().fragment(&node)
@@ -9,6 +9,7 @@
9 9
10 10 use goingson_core::{NewProject, ProjectStatus, ProjectType};
11 11 use quasi_http::Render as _;
12 + use quasi_router::Outcome;
12 13 use quasi_router::{Method, Params, Response};
13 14
14 15 use super::super::{protocol, router};
@@ -87,7 +88,7 @@
87 88 #[tokio::test]
88 89 async fn an_empty_database_says_so_rather_than_rendering_nothing() {
89 90 let state = state().await;
90 - let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
91 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
91 92 panic!("the index answers with a screen");
92 93 };
93 94 let html = quasi_webview::Webview::new().screen(&screen);
@@ -101,7 +102,7 @@
101 102 add(&state, "Paused", ProjectStatus::OnHold);
102 103 add(&state, "Finished", ProjectStatus::Completed);
103 104
104 - let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
105 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
105 106 panic!("a screen");
106 107 };
107 108 let html = quasi_webview::Webview::new().screen(&screen);
@@ -120,7 +121,7 @@
120 121 add(&state, "Finished", ProjectStatus::Completed);
121 122
122 123 let shown = answer(&state, "/projects", Params::new().with("retired", "1"));
123 - let Response::Screen(screen) = shown else {
124 + let Outcome::Screen(screen) = shown.outcome else {
124 125 panic!("a screen");
125 126 };
126 127 let html = quasi_webview::Webview::new().screen(&screen);
@@ -138,7 +139,7 @@
138 139 // reflowed to change one pane.
139 140 assert_eq!(response.target(), Some("projects-grid"));
140 141
141 - let Response::Fragment { node, .. } = response else {
142 + let Outcome::Fragment { node, .. } = (response).outcome else {
142 143 panic!("a fragment");
143 144 };
144 145 let html = quasi_webview::Webview::new().fragment(&node);
@@ -152,7 +153,7 @@
152 153 let state = state().await;
153 154 add(&state, "Mine", ProjectStatus::Active);
154 155
155 - let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
156 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
156 157 panic!("a screen");
157 158 };
158 159 let html = quasi_webview::Webview::new().screen(&screen);
@@ -160,7 +161,8 @@
160 161
161 162 // Asking for the filtered view surfaces the control even with nothing
162 163 // shared, so the way back is always on screen. Same rule as `projects.js`.
163 - let Response::Screen(screen) = answer(&state, "/projects", Params::new().with("shared", "1"))
164 + let Outcome::Screen(screen) =
165 + answer(&state, "/projects", Params::new().with("shared", "1")).outcome
164 166 else {
165 167 panic!("a screen");
166 168 };
@@ -179,7 +181,8 @@
179 181 let state = state().await;
180 182 add(&state, "Mine", ProjectStatus::OnHold);
181 183
182 - let Response::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()) else {
184 + let Outcome::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()).outcome
185 + else {
183 186 panic!("a fragment");
184 187 };
185 188 let html = quasi_webview::Webview::new().fragment(&node);
@@ -201,8 +204,8 @@
201 204 let state = state().await;
202 205 add(&state, "Done", ProjectStatus::Archived);
203 206
204 - let Response::Fragment { node, .. } =
205 - answer(&state, "/projects/list", Params::new().with("retired", "1"))
207 + let Outcome::Fragment { node, .. } =
208 + answer(&state, "/projects/list", Params::new().with("retired", "1")).outcome
206 209 else {
207 210 panic!("a fragment");
208 211 };
@@ -217,7 +220,8 @@
217 220 let state = state().await;
218 221 let project = add(&state, "Mine", ProjectStatus::Active);
219 222
220 - let Response::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()) else {
223 + let Outcome::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()).outcome
224 + else {
221 225 panic!("a fragment");
222 226 };
223 227 let html = quasi_webview::Webview::new().fragment(&node);
@@ -249,7 +253,7 @@
249 253 let state = state().await;
250 254 add(&state, "<script>alert(1)</script>", ProjectStatus::Active);
251 255
252 - let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
256 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
253 257 panic!("a screen");
254 258 };
255 259 let html = quasi_webview::Webview::new().screen(&screen);
@@ -266,14 +270,14 @@
266 270 let state = state().await;
267 271 let project = add(&state, "Mine", ProjectStatus::Active);
268 272
269 - let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
273 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
270 274 panic!("a screen");
271 275 };
272 276 let html = quasi_webview::Webview::new().screen(&screen);
273 277 assert!(html.contains("hx-get=\"/projects/new\""));
274 278
275 - let Response::Fragment { node, .. } =
276 - answer(&state, &format!("/projects/{}", project.id), Params::new())
279 + let Outcome::Fragment { node, .. } =
280 + answer(&state, &format!("/projects/{}", project.id), Params::new()).outcome
277 281 else {
278 282 panic!("a fragment");
279 283 };
@@ -292,7 +296,8 @@
292 296 #[tokio::test]
293 297 async fn the_create_form_asks_what_the_modal_asks() {
294 298 let state = state().await;
295 - let Response::Fragment { node, .. } = answer(&state, "/projects/new", Params::new()) else {
299 + let Outcome::Fragment { node, .. } = answer(&state, "/projects/new", Params::new()).outcome
300 + else {
296 301 panic!("a fragment");
297 302 };
298 303 let html = quasi_webview::Webview::new().fragment(&node);
@@ -313,7 +318,7 @@
313 318 #[tokio::test]
314 319 async fn creating_a_project_puts_it_in_the_grid() {
315 320 let state = state().await;
316 - let Response::Screen(screen) = post(&state, "/projects", valid("Made here")) else {
321 + let Outcome::Screen(screen) = post(&state, "/projects", valid("Made here")).outcome else {
317 322 panic!("a write answers with the whole screen");
318 323 };
319 324 let html = quasi_webview::Webview::new().screen(&screen);
@@ -332,7 +337,7 @@
332 337 let state = state().await;
333 338 let long = "x".repeat(101);
334 339
335 - let Response::Fragment { node, .. } = post(&state, "/projects", valid(&long)) else {
340 + let Outcome::Fragment { node, .. } = post(&state, "/projects", valid(&long)).outcome else {
336 341 panic!("a refusal answers with the form, not the screen");
337 342 };
338 343 let html = quasi_webview::Webview::new().fragment(&node);
@@ -349,7 +354,7 @@
349 354 #[tokio::test]
350 355 async fn a_nameless_project_is_refused_rather_than_created_blank() {
351 356 let state = state().await;
352 - let Response::Fragment { .. } = post(&state, "/projects", valid(" ")) else {
357 + let Outcome::Fragment { .. } = post(&state, "/projects", valid(" ")).outcome else {
353 358 panic!("a refusal");
354 359 };
355 360 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
@@ -362,7 +367,7 @@
362 367 let state = state().await;
363 368 let params = a_project("Mine", "Sculpture", "Active");
364 369
365 - let Response::Fragment { node, .. } = post(&state, "/projects", params) else {
370 + let Outcome::Fragment { node, .. } = post(&state, "/projects", params).outcome else {
366 371 panic!("a refusal");
367 372 };
368 373 let html = quasi_webview::Webview::new().fragment(&node);
@@ -371,7 +376,7 @@
371 376
372 377 // A status that parses but is not on offer is refused on the same grounds.
373 378 let params = a_project("Mine", "SideProject", "Archived");
374 - let Response::Fragment { node, .. } = post(&state, "/projects", params) else {
379 + let Outcome::Fragment { node, .. } = post(&state, "/projects", params).outcome else {
375 380 panic!("a refusal");
376 381 };
377 382 let html = quasi_webview::Webview::new().fragment(&node);
@@ -385,11 +390,13 @@
385 390 let project = add(&state, "Going", ProjectStatus::Active);
386 391 add(&state, "Staying", ProjectStatus::Active);
387 392
388 - let Response::Screen(screen) = post(
393 + let Outcome::Screen(screen) = post(
389 394 &state,
390 395 &format!("/projects/{}/delete", project.id),
391 396 Params::new(),
392 - ) else {
397 + )
398 + .outcome
399 + else {
393 400 panic!("a write answers with the whole screen");
394 401 };
395 402 let html = quasi_webview::Webview::new().screen(&screen);
@@ -425,7 +432,7 @@
425 432 let project = add(&state, "Finished", ProjectStatus::Completed);
426 433 let retired = Params::new().with("retired", "1");
427 434
428 - let Response::Screen(screen) = answer(&state, "/projects", retired.clone()) else {
435 + let Outcome::Screen(screen) = answer(&state, "/projects", retired.clone()).outcome else {
429 436 panic!("a screen");
430 437 };
431 438 let html = quasi_webview::Webview::new().screen(&screen);
@@ -437,8 +444,8 @@
437 444 assert!(html.contains(&format!("hx-get=\"/projects/new\" {vals}")));
438 445 assert!(html.contains(&format!("hx-get=\"/projects/{}\" {vals}", project.id)));
439 446
440 - let Response::Fragment { node, .. } =
441 - answer(&state, &format!("/projects/{}", project.id), retired)
447 + let Outcome::Fragment { node, .. } =
448 + answer(&state, &format!("/projects/{}", project.id), retired).outcome
442 449 else {
443 450 panic!("a fragment");
444 451 };
@@ -455,11 +462,13 @@
455 462 add(&state, "Finished", ProjectStatus::Completed);
456 463 let doomed = add(&state, "Going", ProjectStatus::Completed);
457 464
458 - let Response::Screen(screen) = post(
465 + let Outcome::Screen(screen) = post(
459 466 &state,
460 467 &format!("/projects/{}/delete", doomed.id),
461 468 Params::new().with("retired", "1"),
462 - ) else {
469 + )
470 + .outcome
471 + else {
463 472 panic!("a screen");
464 473 };
465 474 let html = quasi_webview::Webview::new().screen(&screen);
@@ -9,6 +9,7 @@
9 9
10 10 use goingson_core::{NewTask, Priority};
11 11 use quasi_http::Render as _;
12 + use quasi_router::Outcome;
12 13 use quasi_router::{Method, Params, Response};
13 14
14 15 use super::super::router;
@@ -63,9 +64,13 @@
63 64 }
64 65
65 66 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),
67 + match response.outcome {
68 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
69 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
70 + // Deliberately not a wildcard. See the same helper in the dashboard
71 + // tests: a redirect has no body, and a fallback returning empty markup
72 + // would read as a screen that rendered nothing.
73 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
69 74 }
70 75 }
71 76
@@ -278,7 +283,7 @@
278 283 &format!("/tasks/{}/subtasks", task.id),
279 284 Params::new().with("text", "Added here"),
280 285 );
281 - let Response::Screen(_) = &response else {
286 + let Outcome::Screen(_) = &response.outcome else {
282 287 panic!("a write answers with the whole screen");
283 288 };
284 289 let page = html(response);
@@ -357,20 +362,27 @@
357 362 }
358 363
359 364 #[tokio::test]
360 - async fn deleting_a_task_has_nowhere_to_send_the_user() {
361 - // The sixth finding, asserted. Every answer replaces a region or the screen
362 - // in place, and deleting the thing a screen is about is the first case
363 - // where the right answer is a different address. The JS closes the drawer.
365 + async fn deleting_a_task_sends_the_user_to_the_list_and_says_so() {
366 + // The sixth finding, closed 2026-08-09 (`80afd652`). This asserted the
367 + // workaround until then: a tombstone screen, addressed by the id of the
368 + // thing that no longer existed, because every answer was content. The
369 + // workaround is gone, so the assertion had to change rather than be relaxed.
364 370 let state = state().await;
365 371 let task = add(&state, "Going");
366 372
367 - let page = html(post(
368 - &state,
369 - &format!("/tasks/{}/delete", task.id),
370 - Params::new(),
371 - ));
373 + let answer = post(&state, &format!("/tasks/{}/delete", task.id), Params::new());
374 +
375 + let Outcome::Goto(action) = &answer.outcome else {
376 + panic!("a delete answers with somewhere to go, not with content");
377 + };
378 + assert_eq!(action.destination.route(), Some("/tasks"));
379 +
380 + // And it says what happened, which the redirect alone cannot: the list it
381 + // lands on looks the same whether a task was deleted or the user navigated.
382 + let notice = answer.notice.as_ref().expect("a delete says so");
383 + assert_eq!(notice.text, "Task deleted");
384 + assert_eq!(notice.kind, quasi_router::layout::Notice::Toast);
372 385
373 - assert!(page.contains("Task deleted"));
374 386 // And it is really gone.
375 387 let error = router()
376 388 .handle(
@@ -6,6 +6,7 @@
6 6 MilestoneStatus, NewMilestone, NewProject, NewTask, ProjectId, ProjectStatus, ProjectType,
7 7 };
8 8 use quasi_http::Render as _;
9 + use quasi_router::Outcome;
9 10 use quasi_router::{Method, Params, Response};
10 11
11 12 use crate::quasi::router;
@@ -66,9 +67,14 @@
66 67 }
67 68
68 69 fn html(response: Response) -> String {
69 - match response {
70 - Response::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
71 - Response::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
70 + match response.outcome {
71 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
72 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
73 + // A redirect has no body to render. `Outcome` is deliberately not
74 + // `#[non_exhaustive]`, so this arm had to be written rather than falling
75 + // into a wildcard that returned empty markup and looked like a screen
76 + // that rendered nothing.
77 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
72 78 }
73 79 }
74 80
@@ -143,11 +149,13 @@
143 149 let detail = get(&state, &format!("/projects/{project}"), Params::new());
144 150 assert_eq!(detail.target(), Some("projects-detail"));
145 151
146 - let Response::Screen(_) = get(
152 + let Outcome::Screen(_) = get(
147 153 &state,
148 154 &format!("/projects/{project}/dashboard"),
149 155 Params::new(),
150 - ) else {
156 + )
157 + .outcome
158 + else {
151 159 panic!("the dashboard answers with a screen");
152 160 };
153 161 }
@@ -194,7 +202,8 @@
194 202 assert!(page.contains(&format!("hx-get=\"/tasks/{}\"", task.id)));
195 203
196 204 // And it answers.
197 - let Response::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()) else {
205 + let Outcome::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()).outcome
206 + else {
198 207 panic!("the overview answers");
199 208 };
200 209 }