Skip to main content

max / goingson

Give the task list its bulk actions The port shipped without them and that is not a task list. The vocabulary was the reason -- `Cells` had no tick -- so quasi 0.13.0 grew the two members this screen turned out to need, and this is the consumer half. The bar is the shipped one: Complete and Delete over the set, and snooze, project and priority applied to it. Two kinds of control, which is why one member was not enough: said with acts alone the project picker is one button per project. Select-all is an address (`ticked=all`) rather than client state. A webview one needs a script quasi-webview does not ship and a terminal one needs a key it would have to invent; answered from the server it is one query against a local file, every host gets it, it works with JS off, and it survives the fragment swap, which the client-side version does not since the swap replaces the boxes. It does not travel through a filter change: `tasks-filter.js` clears the selection on every filter change so bulk actions cannot target rows the user can no longer see, and carrying it would make "everything" quietly mean a different everything. Bulk complete goes through the same `move_to` a row's own Complete takes, so each task in the set still mints its recurring successor, stops its timer and closes its milestone. A loop over the repository's `complete` would have been this week's other bug, multiplied by the size of the selection. What is still not sayable is the count -- "3 selected", and hiding the bar when nothing is ticked. The ticks are the host's until something submits them, so a renderer knows the number and a description cannot. Recorded on `bulk`; the bar is always on screen, which is the honest version of not knowing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 03:58 UTC
Signed with PGP, not checked
Commit: 6c8dedb6ee8b2604aebe58060db4c5a48c657872
Parent: e8cf6a7
4 files changed, +628 insertions, -51 deletions
M Cargo.lock +21 -21
@@ -1367,7 +1367,7 @@
1367 1367 "libc",
1368 1368 "option-ext",
1369 1369 "redox_users",
1370 - "windows-sys 0.60.2",
1370 + "windows-sys 0.61.2",
1371 1371 ]
1372 1372
1373 1373 [[package]]
@@ -1608,7 +1608,7 @@
1608 1608 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1609 1609 dependencies = [
1610 1610 "libc",
1611 - "windows-sys 0.60.2",
1611 + "windows-sys 0.61.2",
1612 1612 ]
1613 1613
1614 1614 [[package]]
@@ -3593,7 +3593,7 @@
3593 3593 "png 0.18.1",
3594 3594 "serde",
3595 3595 "thiserror 2.0.20",
3596 - "windows-sys 0.60.2",
3596 + "windows-sys 0.61.2",
3597 3597 ]
3598 3598
3599 3599 [[package]]
@@ -3726,7 +3726,7 @@
3726 3726 source = "registry+https://github.com/rust-lang/crates.io-index"
3727 3727 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3728 3728 dependencies = [
3729 - "windows-sys 0.60.2",
3729 + "windows-sys 0.61.2",
3730 3730 ]
3731 3731
3732 3732 [[package]]
@@ -4651,7 +4651,7 @@
4651 4651
4652 4652 [[package]]
4653 4653 name = "quasi-http"
4654 - version = "0.12.0"
4654 + version = "0.13.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.12.0"
4663 + version = "0.13.0"
4664 4664 dependencies = [
4665 4665 "makeover-layout",
4666 4666 ]
4667 4667
4668 4668 [[package]]
4669 4669 name = "quasi-tauri"
4670 - version = "0.12.0"
4670 + version = "0.13.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.12.0"
4680 + version = "0.13.0"
4681 4681 dependencies = [
4682 4682 "docengine",
4683 4683 "makeover-layout",
@@ -4999,7 +4999,7 @@
4999 4999 "pin-project-lite",
5000 5000 "rustls",
5001 5001 "rustls-pki-types",
5002 - "rustls-platform-verifier 0.6.2",
5002 + "rustls-platform-verifier 0.7.0",
5003 5003 "serde",
5004 5004 "serde_json",
5005 5005 "serde_urlencoded",
@@ -5105,7 +5105,7 @@
5105 5105 "errno",
5106 5106 "libc",
5107 5107 "linux-raw-sys",
5108 - "windows-sys 0.60.2",
5108 + "windows-sys 0.61.2",
5109 5109 ]
5110 5110
5111 5111 [[package]]
@@ -5162,7 +5162,7 @@
5162 5162 "security-framework",
5163 5163 "security-framework-sys",
5164 5164 "webpki-root-certs",
5165 - "windows-sys 0.60.2",
5165 + "windows-sys 0.61.2",
5166 5166 ]
5167 5167
5168 5168 [[package]]
@@ -5183,7 +5183,7 @@
5183 5183 "security-framework",
5184 5184 "security-framework-sys",
5185 5185 "webpki-root-certs",
5186 - "windows-sys 0.60.2",
5186 + "windows-sys 0.61.2",
5187 5187 ]
5188 5188
5189 5189 [[package]]
@@ -5713,7 +5713,7 @@
5713 5713 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5714 5714 dependencies = [
5715 5715 "libc",
5716 - "windows-sys 0.60.2",
5716 + "windows-sys 0.61.2",
5717 5717 ]
5718 5718
5719 5719 [[package]]
@@ -6488,7 +6488,7 @@
6488 6488 "getrandom 0.4.3",
6489 6489 "once_cell",
6490 6490 "rustix",
6491 - "windows-sys 0.60.2",
6491 + "windows-sys 0.61.2",
6492 6492 ]
6493 6493
6494 6494 [[package]]
@@ -6510,7 +6510,7 @@
6510 6510 "parking_lot",
6511 6511 "rustix",
6512 6512 "signal-hook",
6513 - "windows-sys 0.60.2",
6513 + "windows-sys 0.61.2",
6514 6514 ]
6515 6515
6516 6516 [[package]]
@@ -6992,7 +6992,7 @@
6992 6992 "png 0.18.1",
6993 6993 "serde",
6994 6994 "thiserror 2.0.20",
6995 - "windows-sys 0.60.2",
6995 + "windows-sys 0.61.2",
6996 6996 ]
6997 6997
6998 6998 [[package]]
@@ -7027,7 +7027,7 @@
7027 7027 dependencies = [
7028 7028 "memoffset",
7029 7029 "tempfile",
7030 - "windows-sys 0.60.2",
7030 + "windows-sys 0.61.2",
7031 7031 ]
7032 7032
7033 7033 [[package]]
@@ -7548,7 +7548,7 @@
7548 7548 source = "registry+https://github.com/rust-lang/crates.io-index"
7549 7549 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7550 7550 dependencies = [
7551 - "windows-sys 0.60.2",
7551 + "windows-sys 0.61.2",
7552 7552 ]
7553 7553
7554 7554 [[package]]
@@ -8379,15 +8379,15 @@
8379 8379
8380 8380 [[patch.unused]]
8381 8381 name = "quasi-axum"
8382 - version = "0.12.0"
8382 + version = "0.13.0"
8383 8383
8384 8384 [[patch.unused]]
8385 8385 name = "quasi-basics"
8386 - version = "0.12.0"
8386 + version = "0.13.0"
8387 8387
8388 8388 [[patch.unused]]
8389 8389 name = "quasi-immediate"
8390 - version = "0.12.0"
8390 + version = "0.13.0"
8391 8391
8392 8392 [[patch.unused]]
8393 8393 name = "quasi-store"
@@ -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.12", optional = true }
49 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.12", optional = true }
50 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.12", optional = true }
51 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.12", optional = true }
48 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.13", optional = true }
49 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.13", optional = true }
50 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.13", optional = true }
51 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.13", optional = true }
52 52
53 53 # Tauri
54 54 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -66,19 +66,37 @@
66 66 //! shows, and asking for more is an address — is what this uses. What is lost is
67 67 //! the renderer knowing the act belongs to the table above it.
68 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.
69 + //! **3. A table row could not join a selection. Closed in quasi 0.13.0.**
70 + //! `Screen::selecting`, [`Row::ticking`](quasi_router::screen::Row::ticking) and
71 + //! [`Act::over`] existed with no consumer anywhere outside quasi's own tests,
72 + //! and `Cells` deliberately had no tick: its doc said "no table asks for one,
73 + //! and a member added because its sibling has it is a member with no consumer
74 + //! to tell us what it should mean." This screen asked, and asking turned out to
75 + //! need two members rather than one — [`Cells::ticking`], and [`Field::over`],
76 + //! because a bulk bar is not only buttons. See [`bulk`] for what the bar holds
77 + //! and [`View::ticked`] for why select-all is an address.
78 + //!
79 + //! What is still not sayable is the **count**: `tasks.js` writes "3 selected"
80 + //! into the bar and hides the bar when nothing is ticked. The ticks are the
81 + //! host's until something submits them, so the description cannot know the
82 + //! number — which is the right place for it, since a renderer knows exactly.
83 + //! A gap in the renderers rather than in the vocabulary, recorded on [`bulk`].
84 + //!
85 + //! Shift-range selection is the host's for the same reason, and always was.
76 86 //!
77 87 //! **4. Saved views are a screen this one does not have.** `saved-views.js`
78 88 //! stores named filter sets. Under this port a view *is* an address, which is
79 89 //! most of what saving one was for, but naming and listing them is a store and a
80 90 //! screen of its own and is not part of describing this one.
81 91 //!
92 + //! # What the bar offers
93 + //!
94 + //! The shipped bar's five, and its two halves are two kinds of control:
95 + //! Complete and Delete act on the set, while snooze, project and priority apply
96 + //! a value to it. The undo the JS wraps each of them in does not survive the
97 + //! port — there is no word for an undo window — so Delete confirms instead, the
98 + //! same trade the row's own Delete makes.
99 + //!
82 100 //! # What the row offers, and what it does not
83 101 //!
84 102 //! Start, Complete, Delete, and the title opens the drawer. The shipped row's
@@ -157,6 +175,22 @@
157 175 ("actions", Width::Fixed, None),
158 176 ];
159 177
178 + /// What the screen calls its set of ticks.
179 + ///
180 + /// One name, in one place: [`Screen::selecting`] declares it, every row joins
181 + /// it, and every control over it names it. See [`Act::over`] for why a renderer
182 + /// does not match the two against each other — a fragment carries no screen —
183 + /// which is exactly why this is a constant rather than a string typed five
184 + /// times.
185 + const SELECTION: &str = "chosen";
186 +
187 + /// What "no project" travels as on the bulk picker.
188 + ///
189 + /// A blank means "the control has not been used", so clearing a project needs a
190 + /// word of its own. The shipped modal has the same two cases and spells the
191 + /// second as a button labelled "No Project" beside a null.
192 + const NONE: &str = "none";
193 +
160 194 /// The statuses the filter offers, in the order the control reads.
161 195 ///
162 196 /// `None` is "every status", which the shipped `<select>` spells as its blank
@@ -261,6 +295,21 @@
261 295 snoozed: bool,
262 296 /// Whether the list is cut to what is waiting on somebody else.
263 297 waiting: bool,
298 + /// Whether the rows arrive ticked.
299 + ///
300 + /// **Select-all, and it is an address rather than client state.** A
301 + /// renderer could tick every box it drew, and a webview one would need a
302 + /// script this crate does not ship; a terminal would need a key it invents.
303 + /// Answering it from the server costs one query against a local SQLite file
304 + /// and every host gets it for free, works with JS off, and survives the
305 + /// fragment swap — which the client-side version does not, since the swap
306 + /// replaces the boxes.
307 + ///
308 + /// Only the arriving state. What the user unticks afterwards is the host's,
309 + /// exactly as it is for a tick they made themselves, and nothing here tries
310 + /// to follow it: a description that tracked individual ticks would be
311 + /// carrying two hundred ids in an address.
312 + ticked: bool,
264 313 /// What the table is ordered by.
265 314 sort: TaskSortColumn,
266 315 /// Which way.
@@ -289,6 +338,7 @@
289 338 priority: None,
290 339 snoozed: false,
291 340 waiting: false,
341 + ticked: false,
292 342 sort: TaskSortColumn::Due,
293 343 direction: SortDirection::Asc,
294 344 shown: PAGE,
@@ -339,6 +389,7 @@
339 389 priority,
340 390 snoozed: matches!(request.carried.get("snoozed"), Some("1" | "true")),
341 391 waiting: matches!(request.carried.get("waiting"), Some("1" | "true")),
392 + ticked: matches!(request.carried.get("ticked"), Some("all")),
342 393 sort,
343 394 direction: match request.carried.get("direction") {
344 395 Some("desc") => SortDirection::Desc,
@@ -381,6 +432,9 @@
381 432 if self.waiting {
382 433 action = action.carrying("waiting", "1");
383 434 }
435 + if self.ticked {
436 + action = action.carrying("ticked", "all");
437 + }
384 438 if self.sort != TaskSortColumn::Due {
385 439 action = action.carrying("sort", sort_word(self.sort));
386 440 }
@@ -398,13 +452,23 @@
398 452 self.carry(Action::get("/tasks/list"))
399 453 }
400 454
401 - /// The same view showing one page.
455 + /// The same view showing one page, with nothing ticked.
402 456 ///
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.
457 + /// A filter change is a new set of rows, so `shown` goes back to one page:
458 + /// carrying it would ask for 2000 rows of a project holding nine.
459 + ///
460 + /// It drops the ticks for a sharper reason. `tasks-filter.js` clears the
461 + /// selection on every filter change by hand, on the stated rule that bulk
462 + /// actions must not target rows the user can no longer see. Here the rule
463 + /// needs no enforcing for the ticks a user made — the answer is a new list
464 + /// and the boxes go with the rows — but `ticked=all` is carried on the
465 + /// address, and carrying it through a filter change is exactly the thing
466 + /// the JS rule forbids: "everything" would silently come to mean a
467 + /// different everything.
405 468 fn first_page(&self) -> Self {
406 469 Self {
407 470 shown: PAGE,
471 + ticked: false,
408 472 ..self.clone()
409 473 }
410 474 }
@@ -628,6 +692,115 @@
628 692 progress,
629 693 Cell::acts(acts_for(task, view)),
630 694 ])
695 + // The row joins the screen's selection under its own id, which is what the
696 + // bulk bar acts on. `quasi` 0.13.0's member; before it, a described table
697 + // could draw a bulk bar with nothing for it to act on.
698 + .ticking(task.id.to_string(), view.ticked)
699 + }
700 +
701 + /// The controls over the selection.
702 + ///
703 + /// The shipped bar's five, and its two halves are two kinds of control.
704 + /// Complete and Delete act on the set; snooze, project and priority apply a
705 + /// value to it, which is why [`Field::over`] had to exist — said as acts, the
706 + /// project picker alone is one button per project.
707 + ///
708 + /// # What is not here
709 + ///
710 + /// The count. `tasks.js` writes "3 selected" into the bar and hides it when the
711 + /// selection is empty, and neither is sayable: the ticks are the host's until
712 + /// something submits them, so the description does not know how many there are.
713 + /// That is the right place for it — a renderer knows exactly, and a webview one
714 + /// can count its own boxes — and it is a gap in the renderers rather than in
715 + /// the vocabulary. The bar is always on screen here, which is the honest
716 + /// version of not knowing.
717 + fn bulk(state: &AppState, view: &View) -> Result<Vec<Node>, RouteError> {
718 + let over = |action: Action| view.carry(action);
719 + let mut out = vec![
720 + Node::Act(Act::new("Complete", over(Action::post("/tasks/list/complete"))).over(SELECTION)),
721 + Node::Act(
722 + Act::new("Delete", over(Action::post("/tasks/list/delete")))
723 + .tone(Tone::Danger)
724 + .over(SELECTION)
725 + .confirm("Delete every selected task? This cannot be undone."),
726 + ),
727 + ];
728 +
729 + // Select-all is an address: see `View::ticked`. Its opposite is the same
730 + // address without it, and only offered when there is something to clear.
731 + out.push(Node::act(
732 + "Select all",
733 + View {
734 + ticked: true,
735 + ..view.clone()
736 + }
737 + .list(),
738 + ));
739 + if view.ticked {
740 + out.push(Node::act(
741 + "Clear selection",
742 + View {
743 + ticked: false,
744 + ..view.clone()
745 + }
746 + .list(),
747 + ));
748 + }
749 +
750 + // A blank leading option on each picker, because a select opens on its
751 + // first option and one that opened on "High" would read as though the
752 + // selection already had a priority. Choosing the blank writes nothing: the
753 + // handlers answer an unparseable value with the list unchanged rather than
754 + // an error, which is the one place on this screen a bad value is not a 404
755 + // — it is the control's resting state, not a wiring mistake.
756 + let mut priorities = vec![Choice::new("", "Set priority")];
757 + priorities.extend(
758 + PRIORITIES
759 + .iter()
760 + .map(|priority| Choice::new(priority_word(priority), priority_word(priority))),
761 + );
762 + out.push(Node::field(
763 + Field::select("priority", "Set priority", priorities)
764 + .changes(over(Action::post("/tasks/list/priority")))
765 + .over(SELECTION),
766 + ));
767 +
768 + let projects = state
769 + .projects
770 + .list_all(DESKTOP_USER_ID)
771 + .map_err(|error| RouteError::internal(error.to_string()))?;
772 + let mut options = vec![
773 + Choice::new("", "Set project"),
774 + Choice::new(NONE, "No project"),
775 + ];
776 + options.extend(
777 + projects
778 + .iter()
779 + .map(|project| Choice::new(project.id.to_string(), &project.name)),
780 + );
781 + out.push(Node::field(
782 + Field::select("project", "Set project", options)
783 + .changes(over(Action::post("/tasks/list/project")))
784 + .over(SELECTION),
785 + ));
786 +
787 + // The same precomputed times the shipped modal offers, from the same
788 + // function, so "Later Today" means one thing in the app. A described screen
789 + // has no modal to put them in and does not need one: they are options.
790 + let mut whens = vec![Choice::new("", "Snooze until")];
791 + whens.extend(
792 + crate::commands::get_snooze_options()
793 + .options
794 + .into_iter()
795 + .map(|option| Choice::new(option.time.to_rfc3339(), option.label)),
796 + );
797 + out.push(Node::field(
798 + Field::select("until", "Snooze until", whens)
799 + .changes(over(Action::post("/tasks/list/snooze")))
800 + .over(SELECTION),
801 + ));
802 +
803 + Ok(out)
631 804 }
632 805
633 806 /// The columns, each carrying what pressing it does.
@@ -876,20 +1049,36 @@
876 1049 .extend(filters(state, &view)?);
877 1050
878 1051 Ok(Screen::list_detail("Tasks", false)
1052 + .selecting(SELECTION)
879 1053 .with(band)
1054 + .with(Slot::new("tasks-bulk", RegionKind::Band).extend(bulk(state, &view)?))
880 1055 .with(Slot::new("tasks-list", RegionKind::Pane).extend(table(state, &view)?))
881 1056 .into())
882 1057 }
883 1058
884 - /// The table alone, which is what a filter, a sort or "show more" swaps.
885 - fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
886 - let view = View::of(&request)?;
1059 + /// The list, and the bar over it, as one answer.
1060 + ///
1061 + /// Two regions rather than one because they are two regions: the bar sits above
1062 + /// the filters' output and outlives a page of it. They travel together on every
1063 + /// answer because the bar changes with the view — "Clear selection" is on it
1064 + /// only when something is ticked — and an answer moving one without the other
1065 + /// would leave a bar offering to clear a selection the rows no longer have.
1066 + fn answer(state: &AppState, view: &View) -> Result<Response, RouteError> {
887 1067 Ok(Response::fragment(
888 1068 "tasks-list",
889 - Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(state, &view)?)),
1069 + Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(state, view)?)),
1070 + )
1071 + .also(
1072 + "tasks-bulk",
1073 + Node::Region(Slot::new("tasks-bulk", RegionKind::Band).extend(bulk(state, view)?)),
890 1074 ))
891 1075 }
892 1076
1077 + /// The table alone, which is what a filter, a sort or "show more" swaps.
1078 + fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1079 + answer(state, &View::of(&request)?)
1080 + }
1081 +
893 1082 /// The task a route was addressed at.
894 1083 fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
895 1084 let raw = request
@@ -920,14 +1109,18 @@
920 1109 fn wrote(
921 1110 state: &AppState,
922 1111 request: &quasi_router::Request,
923 - message: &str,
1112 + message: impl Into<String>,
924 1113 ) -> Result<Response, RouteError> {
925 - let view = View::of(request)?;
926 - Ok(Response::fragment(
927 - "tasks-list",
928 - Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(state, &view)?)),
929 - )
930 - .toast(Tone::Success, message))
1114 + // The ticks are cleared by answering a view that has none. A bulk write
1115 + // whose answer re-ticked every surviving row would leave "everything"
1116 + // meaning something new after every press, which is `tasks.js`'s rule
1117 + // (`selectedTaskIds.clear()` in each of its five bulk paths) arrived at
1118 + // from the other side.
1119 + let view = View {
1120 + ticked: false,
1121 + ..View::of(request)?
1122 + };
1123 + Ok(answer(state, &view)?.toast(Tone::Success, message))
931 1124 }
932 1125
933 1126 /// Start a task, complete it, or send it back to Pending.
@@ -950,11 +1143,7 @@
950 1143 // second recurrence, which is the board's reasoning and matters more here:
951 1144 // a row can be pressed while the list it was drawn in is stale.
952 1145 if task.status == to {
953 - let view = View::of(&request)?;
954 - return Ok(Response::fragment(
955 - "tasks-list",
956 - Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(state, &view)?)),
957 - ));
1146 + return answer(state, &View::of(&request)?);
958 1147 }
959 1148
960 1149 let message = super::move_to(state, &task, &to)?;
@@ -977,12 +1166,184 @@
977 1166 wrote(state, &request, "Task deleted.")
978 1167 }
979 1168
1169 + /// Every task the user ticked, in the order they arrived.
1170 + ///
1171 + /// The ticks come back under one repeated name rather than a joined string,
1172 + /// which is what [`quasi_router::Params::get_all`] is for and why no delimiter
1173 + /// had to be one no id can contain.
1174 + ///
1175 + /// An id that does not parse is dropped rather than refused. A bulk write is
1176 + /// answered by the list it happened in, and failing the whole press because one
1177 + /// value was malformed would lose the other thirty-nine; the count in the toast
1178 + /// is what the user actually gets, so a silent drop still shows up as a smaller
1179 + /// number. An empty set is not an error either — the shipped bar's five paths
1180 + /// all begin `if (selectedTaskIds.size === 0) return;` — so it answers the
1181 + /// unchanged list with nothing said.
1182 + fn chosen(request: &quasi_router::Request) -> Vec<TaskId> {
1183 + request
1184 + .payload
1185 + .get_all(quasi_router::Node::TICKED)
1186 + .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok())
1187 + .map(TaskId::from)
1188 + .collect()
1189 + }
1190 +
1191 + /// `N tasks` or `1 task`, for a toast that counts.
1192 + fn counted(n: usize) -> String {
1193 + if n == 1 {
1194 + "1 task".to_owned()
1195 + } else {
1196 + format!("{n} tasks")
1197 + }
1198 + }
1199 +
1200 + /// Complete every ticked task.
1201 + ///
1202 + /// One at a time through [`super::move_to`], which is the same path a row's own
1203 + /// Complete takes: each one may mint a recurring successor, stop a timer and
1204 + /// close a milestone, and a bulk loop that skipped any of that would be a second
1205 + /// meaning of the word. `tasks.js` reaches the same place by calling its own
1206 + /// per-task API in a `Promise.allSettled`.
1207 + ///
1208 + /// A task that has moved since the list was drawn is skipped rather than
1209 + /// failing the press, which is what `allSettled` buys the JS.
1210 + fn complete_chosen(
1211 + state: &AppState,
1212 + request: quasi_router::Request,
1213 + ) -> Result<Response, RouteError> {
1214 + let mut done = 0;
1215 + for id in chosen(&request) {
1216 + let Ok(task) = load(state, id) else { continue };
1217 + if task.status == TaskStatus::Completed {
1218 + continue;
1219 + }
1220 + if super::move_to(state, &task, &TaskStatus::Completed).is_ok() {
1221 + done += 1;
1222 + }
1223 + }
1224 + wrote(state, &request, format!("{} completed.", counted(done)))
1225 + }
1226 +
1227 + /// Delete every ticked task.
1228 + fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1229 + let mut done = 0;
1230 + for id in chosen(&request) {
1231 + if state
1232 + .tasks
1233 + .delete(id, DESKTOP_USER_ID)
1234 + .map_err(|error| RouteError::internal(error.to_string()))?
1235 + {
1236 + done += 1;
1237 + }
1238 + }
1239 + wrote(state, &request, format!("{} deleted.", counted(done)))
1240 + }
1241 +
1242 + /// Set the priority on every ticked task.
1243 + ///
1244 + /// One transaction, through the repository's own bulk write, which also
1245 + /// recomputes urgency because priority is an input to it. The blank option is
1246 + /// the control's resting state and writes nothing.
1247 + fn priority_chosen(
1248 + state: &AppState,
1249 + request: quasi_router::Request,
1250 + ) -> Result<Response, RouteError> {
1251 + let Some(word) = text(&request.payload, "priority") else {
1252 + return answer(state, &View::of(&request)?);
1253 + };
1254 + let priority = match word.as_str() {
1255 + "High" => Priority::High,
1256 + "Medium" => Priority::Medium,
1257 + "Low" => Priority::Low,
1258 + _ => return Err(RouteError::not_found("not a priority")),
1259 + };
1260 +
1261 + let ids = chosen(&request);
1262 + let done = state
1263 + .tasks
1264 + .bulk_set_priority(DESKTOP_USER_ID, &ids, priority.clone())
1265 + .map_err(|error| RouteError::internal(error.to_string()))?;
1266 +
1267 + wrote(
1268 + state,
1269 + &request,
1270 + format!("{} set to {}.", counted(done), priority_word(&priority)),
1271 + )
1272 + }
1273 +
1274 + /// Move every ticked task to a project, or out of one.
1275 + fn project_chosen(
1276 + state: &AppState,
1277 + request: quasi_router::Request,
1278 + ) -> Result<Response, RouteError> {
1279 + let Some(raw) = text(&request.payload, "project") else {
1280 + return answer(state, &View::of(&request)?);
1281 + };
1282 + // "No project" is a word rather than a blank, because a blank is the
1283 + // control saying nothing happened.
1284 + let project = if raw == NONE {
1285 + None
1286 + } else {
1287 + Some(ProjectId::from(
1288 + uuid::Uuid::parse_str(&raw).map_err(|_| RouteError::not_found("not a project id"))?,
1289 + ))
1290 + };
1291 +
1292 + let ids = chosen(&request);
1293 + let done = state
1294 + .tasks
1295 + .bulk_set_project(DESKTOP_USER_ID, &ids, project)
1296 + .map_err(|error| RouteError::internal(error.to_string()))?;
1297 +
1298 + wrote(
1299 + state,
1300 + &request,
1301 + match project {
1302 + Some(_) => format!("{} moved.", counted(done)),
1303 + None => format!("{} taken out of their project.", counted(done)),
1304 + },
1305 + )
1306 + }
1307 +
1308 + /// Snooze every ticked task until a time the user picked.
1309 + ///
1310 + /// No bulk repository write for this one, so it is a loop. The times are the
1311 + /// precomputed options the shipped modal offers, from the same function, so
1312 + /// "Later Today" means one thing across the app.
1313 + fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1314 + let Some(raw) = text(&request.payload, "until") else {
1315 + return answer(state, &View::of(&request)?);
1316 + };
1317 + let until = chrono::DateTime::parse_from_rfc3339(&raw)
1318 + .map_err(|_| RouteError::not_found("not a time"))?
1319 + .with_timezone(&chrono::Utc);
1320 +
1321 + let mut done = 0;
1322 + for id in chosen(&request) {
1323 + if state
1324 + .tasks
1325 + .snooze(id, DESKTOP_USER_ID, until)
1326 + .map_err(|error| RouteError::internal(error.to_string()))?
1327 + .is_some()
1328 + {
1329 + done += 1;
1330 + }
1331 + }
1332 +
1333 + wrote(state, &request, format!("{} snoozed.", counted(done)))
1334 + }
1335 +
980 1336 /// The task list's routes.
981 1337 #[must_use]
Lines truncated
@@ -563,6 +563,227 @@
563 563 assert!(markup.contains("cannot be undone"), "{markup}");
564 564 }
565 565
566 + // Bulk actions
567 +
568 + /// A bulk write: the ticks it acts on, plus the view it was pressed under.
569 + fn over(state: &AppState, path: &str, ticks: &[TaskId], mut payload: Params) -> Response {
570 + for id in ticks {
571 + payload = payload.with(quasi_router::Node::TICKED, id.to_string());
572 + }
573 + post(state, path, payload, Params::new())
574 + }
575 +
576 + #[tokio::test]
577 + async fn every_row_joins_the_screens_selection() {
578 + let state = state().await;
579 + let id = task(&state, "Tickable");
580 +
581 + let markup = screen(&state);
582 +
583 + // `Cells::ticking`, which quasi 0.13.0 added for this screen. Before it a
584 + // described table could draw a bulk bar with nothing for it to act on, so
585 + // the port shipped without one.
586 + assert!(
587 + markup.contains(&format!("name=\"ticked\" value=\"{id}\"")),
588 + "{markup}"
589 + );
590 + // And the controls say which set they act on, so a renderer gathers the
591 + // boxes without the app binding anything.
592 + assert!(markup.contains("hx-include=\".row-select\""), "{markup}");
593 + }
594 +
595 + #[tokio::test]
596 + async fn select_all_is_an_address_rather_than_a_script() {
597 + let state = state().await;
598 + task(&state, "One");
599 + task(&state, "Two");
600 +
601 + let plain = screen(&state);
602 + assert_eq!(plain.matches(" checked").count(), 0, "{plain}");
603 +
604 + // A webview select-all needs a script quasi-webview does not ship and a
605 + // terminal one needs a key it would have to invent. Answered from the
606 + // server it costs one query, works in every host, and survives the
607 + // fragment swap -- which the client-side version does not, because the
608 + // swap replaces the boxes.
609 + let all = html(get(&state, "/tasks", Params::new().with("ticked", "all")));
610 + assert_eq!(all.matches(" checked").count(), 2, "{all}");
611 + assert!(all.contains("Clear selection"), "{all}");
612 + }
613 +
614 + #[tokio::test]
615 + async fn a_filter_change_does_not_carry_select_all_through_it() {
616 + let state = state().await;
617 + task(&state, "One");
618 +
619 + let all = html(get(&state, "/tasks", Params::new().with("ticked", "all")));
620 +
621 + // `tasks-filter.js` clears the selection on every filter change by hand, on
622 + // the rule that bulk actions must not target rows the user can no longer
623 + // see. Carrying `ticked=all` through a filter would make "everything" mean
624 + // a different everything, silently.
625 + assert!(!all.contains("priority=High&amp;ticked=all"), "{all}");
626 + assert!(!all.contains("ticked=all&amp;priority=High"), "{all}");
627 + }
628 +
629 + #[tokio::test]
630 + async fn completing_a_selection_completes_each_one_properly() {
631 + let state = state().await;
632 + let plain = task(&state, "Plain");
633 + let recurring = state
634 + .tasks
635 + .create(
636 + DESKTOP_USER_ID,
637 + NewTask::builder("Water the plants")
638 + .priority(Priority::Low)
639 + .due(chrono::Utc::now())
640 + .recurrence(Recurrence::Weekly)
641 + .build(),
642 + )
643 + .unwrap()
644 + .id;
645 +
646 + let response = over(
647 + &state,
648 + "/tasks/list/complete",
649 + &[plain, recurring],
650 + Params::new(),
651 + );
652 +
653 + assert_eq!(
654 + state
655 + .tasks
656 + .get_by_id(plain, DESKTOP_USER_ID)
657 + .unwrap()
658 + .unwrap()
659 + .status,
660 + TaskStatus::Completed
661 + );
662 +
663 + // Through the same `move_to` a row's own Complete takes, so the recurring
664 + // one still mints its successor. A bulk loop calling the repository's
665 + // `complete` would have ended the chain on every task in the set at once,
666 + // which is the single-task bug multiplied.
667 + let markup = html(response);
668 + assert!(markup.contains("Water the plants"), "{markup}");
669 + }
670 +
671 + #[tokio::test]
672 + async fn a_bulk_write_says_how_many_it_touched() {
673 + let state = state().await;
674 + let one = task(&state, "One");
675 + let two = task(&state, "Two");
676 +
677 + let response = over(&state, "/tasks/list/delete", &[one, two], Params::new());
678 + assert_eq!(
679 + response.notice.as_ref().map(|notice| notice.text.as_str()),
680 + Some("2 tasks deleted.")
681 + );
682 +
683 + // Singular is its own sentence, because "1 tasks" is how a screen tells you
684 + // nobody read it.
685 + let three = task(&state, "Three");
686 + let single = over(&state, "/tasks/list/delete", &[three], Params::new());
687 + assert_eq!(
688 + single.notice.as_ref().map(|notice| notice.text.as_str()),
689 + Some("1 task deleted.")
690 + );
691 + }
692 +
693 + #[tokio::test]
694 + async fn a_picker_sets_the_value_on_the_whole_selection() {
695 + let state = state().await;
696 + let one = task(&state, "One");
697 + let two = task(&state, "Two");
698 +
699 + // `Field::over`, the other member this screen asked for. Said with acts
700 + // alone the project picker would be one button per project.
701 + over(
702 + &state,
703 + "/tasks/list/priority",
704 + &[one, two],
705 + Params::new().with("priority", "High"),
706 + );
707 +
708 + for id in [one, two] {
709 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
710 + assert_eq!(after.priority, Priority::High);
711 + }
712 + }
713 +
714 + #[tokio::test]
715 + async fn the_pickers_resting_state_writes_nothing() {
716 + let state = state().await;
717 + let id = task(&state, "Untouched");
718 +
719 + // A select opens on its first option, and that option is a label rather
720 + // than a value. Choosing it must not set a priority nobody asked for.
721 + let response = over(
722 + &state,
723 + "/tasks/list/priority",
724 + &[id],
725 + Params::new().with("priority", ""),
726 + );
727 +
728 + assert!(response.notice.is_none(), "{:?}", response.notice);
729 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
730 + assert_eq!(after.priority, Priority::Medium);
731 + }
732 +
733 + #[tokio::test]
734 + async fn a_bulk_write_over_nothing_is_answered_rather_than_refused() {
735 + let state = state().await;
736 + task(&state, "Untouched");
737 +
738 + // Every one of the shipped bar's five paths opens with
739 + // `if (selectedTaskIds.size === 0) return;`. Pressing a control with an
740 + // empty selection is a thing users do, not a wiring mistake.
741 + let response = over(&state, "/tasks/list/complete", &[], Params::new());
742 + assert!(matches!(response.outcome, Outcome::Fragment { .. }));
743 + assert_eq!(
744 + response.notice.as_ref().map(|notice| notice.text.as_str()),
745 + Some("0 tasks completed.")
746 + );
747 + }
748 +
749 + #[tokio::test]
750 + async fn a_bulk_write_clears_the_selection_it_acted_on() {
751 + let state = state().await;
752 + let one = task(&state, "One");
753 + task(&state, "Two");
754 +
755 + // Pressed from a select-all view. The answer must not re-tick what is left,
756 + // or "everything" quietly means something new after every press --
757 + // `tasks.js` clears the set in each of its five bulk paths for the same
758 + // reason.
759 + let response = post(
760 + &state,
761 + "/tasks/list/delete",
762 + Params::new().with(quasi_router::Node::TICKED, one.to_string()),
763 + Params::new().with("ticked", "all"),
764 + );
765 +
766 + let markup = html(response);
767 + assert!(markup.contains("Two"), "{markup}");
768 + assert_eq!(markup.matches(" checked").count(), 0, "{markup}");
769 + }
770 +
771 + #[tokio::test]
772 + async fn the_bar_travels_with_the_list_it_acts_on() {
773 + let state = state().await;
774 + task(&state, "One");
775 +
776 + // Two regions in one answer. A bar moved without its rows would offer to
777 + // clear a selection the rows no longer have.
778 + let response = get(&state, "/tasks/list", Params::new().with("ticked", "all"));
779 + let regions: Vec<&str> = response
780 + .invalidates
781 + .iter()
782 + .map(|other| other.region.as_str())
783 + .collect();
784 + assert_eq!(regions, ["tasks-bulk"]);
785 + }
786 +
566 787 // Nothing to show
567 788
568 789 #[tokio::test]