| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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]
|