Skip to main content

max / goingson

52.5 KB · 1471 lines History Blame Raw
1 //! The task list, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Three modules serve tasks and they are easy to confuse:
6 //! [`tasks`](super::tasks) is the single-task drawer at `GET /tasks/{id}`,
7 //! [`board`](super::board) is the kanban, and this is the list.
8 //!
9 //! # The shape
10 //!
11 //! - `GET /tasks` — the whole screen.
12 //! - `GET /tasks/list` — the table alone, which is what a filter, a sort, or
13 //! "show more" swaps.
14 //! - `POST /tasks/list/{id}/status` — start it, complete it, or send it back to
15 //! Pending, carrying `status`.
16 //! - `POST /tasks/list/{id}/delete` — delete it.
17 //!
18 //! The writes are under `/tasks/list/` rather than beside the drawer's own
19 //! `POST /tasks/{id}/complete`: a write is answered by the region it happened
20 //! in, the drawer answers with the drawer, and one route cannot answer both.
21 //! The three status writes themselves are [`super::move_to`], shared with the
22 //! board, because "set the status column" is three different writes and two
23 //! copies of that is two answers to what completing a task means.
24 //!
25 //! # The table
26 //!
27 //! Seven columns, four of them sortable, described as
28 //! [`COLUMNS`]. `build.rs` holds the same seven as `makeover_layout::Column`
29 //! for the narrowing CSS it emits; the runtime one carries an address and the
30 //! build-time one cannot. Nothing checks that the two agree, so add a column in
31 //! both by hand.
32 //!
33 //! A [`Table`] carries a [`Rest`](quasi_router::screen::Rest), which is a
34 //! `layout::Paging`: the description carries where the reader is rather than
35 //! merely that there is more. At `PAGE * PAGES` there is no address that would
36 //! show anything new, so the ceiling is a sentence rather than a control, and
37 //! narrowing is the way through a list that long.
38 //!
39 //! Virtual scrolling is a renderer technique over rows the app already holds,
40 //! and a description has no word for it. A `Rest` is a fact about rows that
41 //! were never fetched, which is a different thing.
42 //!
43 //! # Selection
44 //!
45 //! A table row joins a selection through `Screen::selecting`,
46 //! [`Row::ticking`] and [`Act::over`]; the picker half of a bulk bar is
47 //! [`Act::asks`]. See [`bulk`] for what the bar holds and [`View::ticked`] for
48 //! why select-all is an address.
49 //!
50 //! The **count** of ticked rows is not sayable: the ticks are the host's until
51 //! something submits them, so the description cannot know the number and a
52 //! renderer knows exactly. Recorded on [`bulk`]. Shift-range selection is the
53 //! host's for the same reason.
54 //!
55 //! Saved views are a screen this one does not have. A view *is* an address
56 //! here; naming and listing views is a store and a screen of its own.
57 //!
58 //! # What the bar offers
59 //!
60 //! Five controls in two kinds: Complete and Delete act on the set, while
61 //! snooze, project and priority apply a value to it. There is no word for an
62 //! undo window, so Delete confirms instead, the same trade the row's own Delete
63 //! makes.
64 //!
65 //! # What the row offers, and what it does not
66 //!
67 //! Start, Complete, Delete, and the title opens the drawer. Edit, Manage
68 //! Subtasks, Add Note, Attachments, Snooze, Schedule, Track Time and Focus all
69 //! live on the drawer the title opens: each is a modal over the list, and a
70 //! modal form over a screen is a second arrangement this screen would have to
71 //! describe before it could offer it.
72 //!
73 //! Deletion is immediate, so it is confirmed, which is the standard the drawer
74 //! and the projects screen hold.
75 //!
76 //! # The default order
77 //!
78 //! `due` ascending, not `list_tasks_filtered`'s urgency descending. Urgency is
79 //! a column in [`TaskSortColumn`] with no heading to press, so it is a sort
80 //! this list cannot reach.
81
82 // Handlers take their request by value because `quasi_router::Handler` is a
83 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
84 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
85 #![allow(clippy::needless_pass_by_value)]
86
87 use std::time::{Duration, SystemTime};
88
89 use goingson_core::{
90 MilestoneId, Priority, ProjectId, SortDirection, Task, TaskFilterQuery, TaskId, TaskSortColumn,
91 TaskStatus,
92 };
93 use makeover_layout::{Sort, Tone};
94 use quasi_declare::declare;
95 use quasi_router::screen::{Choice, Consult, Figure, Meter, Rest, Tag};
96 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot};
97
98 use crate::state::{AppState, DESKTOP_USER_ID};
99
100 #[cfg(test)]
101 mod tests;
102
103 /// How many rows a page is.
104 ///
105 /// One scroll's worth of rows.
106 const PAGE: i64 = 200;
107
108 /// The ceiling on `shown`.
109 ///
110 /// Ten pages, which stops a hand-typed address asking for a million rows.
111 const PAGES: i64 = 10;
112
113 /// What the screen calls its set of ticks.
114 ///
115 /// One name, in one place: [`Screen::selecting`] declares it, every row joins
116 /// it, and every control over it names it. See [`Act::over`] for why a renderer
117 /// does not match the two against each other — a fragment carries no screen —
118 /// which is exactly why this is a constant rather than a string typed five
119 /// times.
120 const SELECTION: &str = "chosen";
121
122 /// What "no project" travels as on the bulk picker.
123 ///
124 /// A blank means "the control has not been used", so clearing a project needs a
125 /// word of its own.
126 const NONE: &str = "none";
127
128 /// The statuses the filter offers, in the order the control reads.
129 ///
130 /// `None` is "every status". `Deleted` is not offered: a deleted task is not on the list, and
131 /// `list_filtered` does not return one.
132 const STATUSES: [Option<TaskStatus>; 4] = [
133 Some(TaskStatus::Pending),
134 Some(TaskStatus::Started),
135 Some(TaskStatus::Completed),
136 None,
137 ];
138
139 /// The priorities the filter offers.
140 const PRIORITIES: [Priority; 3] = [Priority::High, Priority::Medium, Priority::Low];
141
142 /// The word a priority travels and reads as.
143 ///
144 /// **Not `Priority::as_str`, which is the single letter `H`/`M`/`L`.** That
145 /// method is a display abbreviation for the priority *column*, where the cell is
146 /// one character wide; `TaskStatus::as_str` next to it is the stored word. Two
147 /// methods, one name, two different kinds of answer — and the filter chips were
148 /// built with the wrong one, so every priority chip on this screen offered
149 /// `?priority=H`, which [`View::of`] answers with a 404. Caught by the test that
150 /// presses one.
151 const fn priority_word(priority: &Priority) -> &'static str {
152 match priority {
153 Priority::High => "High",
154 Priority::Medium => "Medium",
155 Priority::Low => "Low",
156 }
157 }
158
159 /// The tone a priority wears. [`super::tasks`]'s `priority_tone`, which is the
160 /// same mapping and is not re-derived here on purpose.
161 const fn priority_tone(priority: &Priority) -> Tone {
162 match priority {
163 Priority::High => Tone::Danger,
164 Priority::Medium => Tone::Warning,
165 Priority::Low => Tone::Neutral,
166 }
167 }
168
169 /// The word a sort column travels as.
170 ///
171 /// `TaskSortColumn::from_str_or_default` reads these and defaults silently,
172 /// which is right for a command taking whatever a caller sent and wrong for an
173 /// address; [`View::of`] parses strictly against the same words.
174 const fn sort_word(column: TaskSortColumn) -> &'static str {
175 match column {
176 TaskSortColumn::Description => "description",
177 TaskSortColumn::Project => "project",
178 TaskSortColumn::Priority => "priority",
179 TaskSortColumn::Due => "due",
180 TaskSortColumn::Urgency => "urgency",
181 }
182 }
183
184 /// A param that is present and not blank. Blank is absent, which is what the
185 /// "all projects" option means.
186 fn text(params: &quasi_router::Params, name: &str) -> Option<String> {
187 params
188 .get(name)
189 .map(str::trim)
190 .filter(|value| !value.is_empty())
191 .map(str::to_owned)
192 }
193
194 /// A uuid-shaped param, or a 404.
195 ///
196 /// An id that does not parse is a wiring mistake, and answering it with the
197 /// unfiltered list hides one.
198 fn id_param<T: From<uuid::Uuid>>(
199 params: &quasi_router::Params,
200 name: &str,
201 ) -> Result<Option<T>, RouteError> {
202 match text(params, name) {
203 None => Ok(None),
204 Some(raw) => uuid::Uuid::parse_str(&raw)
205 .map(|id| Some(T::from(id)))
206 .map_err(|_| RouteError::not_found("not an id")),
207 }
208 }
209
210 /// Which rows, in what order, and how many of them.
211 ///
212 /// Query params rather than module state, per decision 2: a view is an address,
213 /// and the address is the only copy.
214 #[derive(Clone, PartialEq)]
215 struct View {
216 /// The status being looked at. `None` is every status.
217 status: Option<TaskStatus>,
218 /// The project being looked at, if it is one project.
219 project: Option<ProjectId>,
220 /// The milestone within that project, if it is one milestone.
221 milestone: Option<MilestoneId>,
222 /// The priority being looked at, if it is one priority.
223 priority: Option<Priority>,
224 /// Whether snoozed tasks are included.
225 snoozed: bool,
226 /// Whether the list is cut to what is waiting on somebody else.
227 waiting: bool,
228 /// Whether the rows arrive ticked.
229 ///
230 /// **Select-all, and it is an address rather than client state.** A
231 /// renderer could tick every box it drew, and a webview one would need a
232 /// script this crate does not ship; a terminal would need a key it invents.
233 /// Answering it from the server costs one query against a local SQLite file
234 /// and every host gets it for free, works with JS off, and survives the
235 /// fragment swap — which the client-side version does not, since the swap
236 /// replaces the boxes.
237 ///
238 /// Only the arriving state. What the user unticks afterwards is the host's,
239 /// exactly as it is for a tick they made themselves, and nothing here tries
240 /// to follow it: a description that tracked individual ticks would be
241 /// carrying two hundred ids in an address.
242 ticked: bool,
243 /// What the table is ordered by.
244 sort: TaskSortColumn,
245 /// Which way.
246 direction: SortDirection,
247 /// How many rows are on screen.
248 ///
249 /// An address cannot append, so this says how many rows the list shows and
250 /// the query asks for that many from the top. The address re-opens to what
251 /// it described.
252 shown: i64,
253 }
254
255 impl Default for View {
256 /// What `GET /tasks` with no params is: pending work, most urgent first by
257 /// due date, one page of it.
258 fn default() -> Self {
259 Self {
260 status: Some(TaskStatus::Pending),
261 project: None,
262 milestone: None,
263 priority: None,
264 snoozed: false,
265 waiting: false,
266 ticked: false,
267 sort: TaskSortColumn::Due,
268 direction: SortDirection::Asc,
269 shown: PAGE,
270 }
271 }
272 }
273
274 impl View {
275 /// The view a route was addressed at.
276 fn of(request: &quasi_router::Request) -> Result<Self, RouteError> {
277 let status = match text(&request.carried, "status") {
278 None => Some(TaskStatus::Pending),
279 Some(word) if word.eq_ignore_ascii_case("all") => None,
280 Some(word) => Some(match word.as_str() {
281 "Pending" => TaskStatus::Pending,
282 "Started" => TaskStatus::Started,
283 "Completed" => TaskStatus::Completed,
284 _ => return Err(RouteError::not_found("not a task status")),
285 }),
286 };
287
288 let priority = match text(&request.carried, "priority") {
289 None => None,
290 Some(word) => Some(match word.as_str() {
291 "High" => Priority::High,
292 "Medium" => Priority::Medium,
293 "Low" => Priority::Low,
294 _ => return Err(RouteError::not_found("not a priority")),
295 }),
296 };
297
298 let sort = match text(&request.carried, "sort") {
299 None => TaskSortColumn::Due,
300 Some(word) => match word.as_str() {
301 "description" => TaskSortColumn::Description,
302 "project" => TaskSortColumn::Project,
303 "priority" => TaskSortColumn::Priority,
304 "due" => TaskSortColumn::Due,
305 "urgency" => TaskSortColumn::Urgency,
306 _ => return Err(RouteError::not_found("not a sortable column")),
307 },
308 };
309
310 Ok(Self {
311 status,
312 project: id_param(&request.carried, "project")?,
313 milestone: id_param(&request.carried, "milestone")?,
314 priority,
315 snoozed: matches!(request.carried.get("snoozed"), Some("1" | "true")),
316 waiting: matches!(request.carried.get("waiting"), Some("1" | "true")),
317 ticked: matches!(request.carried.get("ticked"), Some("all")),
318 sort,
319 direction: match request.carried.get("direction") {
320 Some("desc") => SortDirection::Desc,
321 _ => SortDirection::Asc,
322 },
323 // Clamped rather than refused, for the reason the mail list gives:
324 // this is an address, and landing on the first page is a more
325 // useful answer than an error page.
326 shown: request
327 .carried
328 .get("shown")
329 .and_then(|raw| raw.parse::<i64>().ok())
330 .unwrap_or(PAGE)
331 .clamp(PAGE, PAGE * PAGES),
332 })
333 }
334
335 /// The same action, still pointed at the view it was offered under.
336 ///
337 /// A default is never written, so two addresses for one view cannot exist.
338 fn carry(&self, action: Action) -> Action {
339 let mut action = action;
340 match &self.status {
341 Some(TaskStatus::Pending) => {}
342 Some(status) => action = action.carrying("status", status.as_str()),
343 None => action = action.carrying("status", "all"),
344 }
345 if let Some(project) = self.project {
346 action = action.carrying("project", project.to_string());
347 }
348 if let Some(milestone) = self.milestone {
349 action = action.carrying("milestone", milestone.to_string());
350 }
351 if let Some(priority) = &self.priority {
352 action = action.carrying("priority", priority_word(priority));
353 }
354 if self.snoozed {
355 action = action.carrying("snoozed", "1");
356 }
357 if self.waiting {
358 action = action.carrying("waiting", "1");
359 }
360 if self.ticked {
361 action = action.carrying("ticked", "all");
362 }
363 if self.sort != TaskSortColumn::Due {
364 action = action.carrying("sort", sort_word(self.sort));
365 }
366 if self.direction == SortDirection::Desc {
367 action = action.carrying("direction", "desc");
368 }
369 if self.shown != PAGE {
370 action = action.carrying("shown", self.shown.to_string());
371 }
372 action
373 }
374
375 /// The address of the table under this view.
376 fn list(&self) -> Action {
377 self.carry(Action::get("/tasks/list"))
378 }
379
380 /// The same view showing one page, with nothing ticked.
381 ///
382 /// A filter change is a new set of rows, so `shown` goes back to one page:
383 /// carrying it would ask for 2000 rows of a project holding nine.
384 ///
385 /// It drops the ticks too. Bulk actions must not target rows the user can
386 /// no longer see: the boxes a user made go with the rows, but `ticked=all`
387 /// rides on the address, and carrying it through a filter change would let
388 /// "everything" silently come to mean a different everything.
389 fn first_page(&self) -> Self {
390 Self {
391 shown: PAGE,
392 ticked: false,
393 ..self.clone()
394 }
395 }
396
397 /// The view ordered by this column: flipped if it is already the sort,
398 /// ascending if it is not.
399 ///
400 /// `sortTasks`, which also has an "descending if urgency" branch that has
401 /// never run because urgency has no heading to press.
402 fn sorted_by(&self, column: TaskSortColumn) -> Self {
403 Self {
404 sort: column,
405 direction: if self.sort == column {
406 match self.direction {
407 SortDirection::Asc => SortDirection::Desc,
408 SortDirection::Desc => SortDirection::Asc,
409 }
410 } else {
411 SortDirection::Asc
412 },
413 ..self.first_page()
414 }
415 }
416
417 /// The same view under one status.
418 fn with_status(&self, status: Option<TaskStatus>) -> Self {
419 Self {
420 status,
421 ..self.first_page()
422 }
423 }
424
425 /// The same view with this priority on, or off if it already was.
426 ///
427 /// Pressing the latched one clears it, so the way back is always on screen.
428 /// The contacts tag filter's rule.
429 fn toggling_priority(&self, priority: &Priority) -> Self {
430 Self {
431 priority: (self.priority.as_ref() != Some(priority)).then(|| priority.clone()),
432 ..self.first_page()
433 }
434 }
435
436 /// The same view with the snoozed rows the other way round.
437 fn toggling_snoozed(&self) -> Self {
438 Self {
439 snoozed: !self.snoozed,
440 ..self.first_page()
441 }
442 }
443
444 /// The same view cut to what is waiting, or not.
445 fn toggling_waiting(&self) -> Self {
446 Self {
447 waiting: !self.waiting,
448 ..self.first_page()
449 }
450 }
451
452 /// The same view with no project and no milestone.
453 ///
454 /// Clearing the project clears the milestone with it: a milestone id that
455 /// outlived its project filters to a project the view no longer names, and
456 /// the control that would clear it is not on screen.
457 fn clearing_project(&self) -> Self {
458 Self {
459 project: None,
460 milestone: None,
461 ..self.first_page()
462 }
463 }
464
465 /// The same view with no milestone.
466 fn clearing_milestone(&self) -> Self {
467 Self {
468 milestone: None,
469 ..self.first_page()
470 }
471 }
472
473 /// The same view with every row arriving ticked, or none of them.
474 fn ticking(&self, ticked: bool) -> Self {
475 Self {
476 ticked,
477 ..self.clone()
478 }
479 }
480
481 /// The same view showing one more page.
482 fn showing(&self, shown: i64) -> Self {
483 Self {
484 shown,
485 ..self.clone()
486 }
487 }
488
489 /// What this view asks the repository for.
490 fn query(&self) -> TaskFilterQuery {
491 TaskFilterQuery {
492 status: self.status.clone(),
493 project_id: self.project,
494 milestone_id: self.milestone,
495 priority: self.priority.clone(),
496 show_snoozed: self.snoozed,
497 waiting_only: self.waiting,
498 offset: Some(0),
499 limit: Some(self.shown),
500 sort_column: Some(self.sort),
501 sort_direction: Some(self.direction),
502 }
503 }
504
505 /// Whether anything has been narrowed. What "Clear filters" is offered for,
506 /// and it deliberately ignores the sort and the page: neither hides a row.
507 fn filtered(&self) -> bool {
508 let default = Self::default();
509 self.status != default.status
510 || self.project.is_some()
511 || self.milestone.is_some()
512 || self.priority.is_some()
513 || self.snoozed
514 || self.waiting
515 }
516 }
517
518 /// Minutes, the way the row says them. `formatMinutes` in `tasks-render.js`.
519 fn minutes(total: i32) -> String {
520 if total >= 60 {
521 format!("{}h {}m", total / 60, total % 60)
522 } else {
523 format!("{total}m")
524 }
525 }
526
527 /// What the row says about time spent on the task.
528 ///
529 /// The tracked-against-estimated badge, for a task whose timer is not running.
530 /// A running one says how long it has been running instead, and that is
531 /// [`running_for`] rather than a badge: a readout derived from the current time
532 /// is a node carrying an instant, so the row says when the timer started and
533 /// the renderer says how long ago that was. A badge saying "18m" would be
534 /// describing the moment it was rendered.
535 fn time_token(task: &Task) -> Option<Tag> {
536 if task.has_active_timer() {
537 return None;
538 }
539 let tracked = task.actual_minutes;
540 match (tracked, task.estimated_minutes) {
541 (0, None) => None,
542 (_, Some(estimate)) => Some(
543 Tag::badge(format!("{} / {}", minutes(tracked), minutes(estimate))).tone(
544 if task.is_over_estimate() {
545 Tone::Warning
546 } else {
547 Tone::Neutral
548 },
549 ),
550 ),
551 (_, None) => Some(Tag::badge(minutes(tracked)).tone(Tone::Neutral)),
552 }
553 }
554
555 /// When a running timer started, for the readout that counts up from it.
556 ///
557 /// `None` when nothing is running, which is every row that is not the one being
558 /// worked on. This is the whole of the app's half: the words, the format and the
559 /// tick are the renderer's, which is what let `time-tracking.js`'s per-second
560 /// subtraction stop being the app's problem.
561 fn running_for(task: &Task) -> Option<SystemTime> {
562 let started = task.active_session.as_ref()?.started_at;
563 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(u64::try_from(started.timestamp()).ok()?))
564 }
565
566 /// What a task's commits say about themselves.
567 fn commit_label(task: &Task) -> &'static str {
568 if task.status_token_summary() == "complete" {
569 "Commits pushed"
570 } else {
571 "Commits unpushed"
572 }
573 }
574
575 /// And what that means.
576 fn commit_tone(task: &Task) -> Tone {
577 if task.status_token_summary() == "complete" {
578 Tone::Success
579 } else {
580 Tone::Warning
581 }
582 }
583
584 /// Whether a waiting task has been waiting too long.
585 fn waiting_tone(task: &Task) -> Tone {
586 if task.is_response_overdue() {
587 Tone::Warning
588 } else {
589 Tone::Neutral
590 }
591 }
592
593 /// Whether the task can still be finished.
594 fn can_complete(task: &Task) -> bool {
595 matches!(task.status, TaskStatus::Pending | TaskStatus::Started)
596 }
597
598 /// Whether the task has subtasks to show progress over.
599 fn has_subtasks(task: &Task) -> bool {
600 task.subtask_count() > 0
601 }
602
603 /// A count as a meter reads it. Never negative, never overflowing.
604 fn measured(n: usize) -> u32 {
605 u32::try_from(n).unwrap_or(u32::MAX)
606 }
607
608 /// What the progress cell says when there is nothing to measure.
609 fn progress_dash(task: &Task) -> &'static str {
610 if has_subtasks(task) { "" } else { "-" }
611 }
612
613 /// What the recurrence cell says.
614 fn recurrence_word(task: &Task) -> &str {
615 if task.has_recurrence() {
616 task.recurrence.as_str()
617 } else {
618 "-"
619 }
620 }
621
622 /// What a due date means.
623 ///
624 /// Overdue is a judgment the app makes and a renderer cannot, so it travels as a
625 /// tone rather than as the `task-overdue` class the JS puts on the row.
626 fn due_tone(task: &Task) -> Tone {
627 if task.is_overdue() {
628 Tone::Danger
629 } else {
630 Tone::Neutral
631 }
632 }
633
634 declare! {
635 /// One task as a row of cells, each naming the column it belongs to.
636 ///
637 /// The names are [`columns`]'s own words, resolved by [`Table::row`] against
638 /// the columns declared beside them. Every row does carry all seven cells,
639 /// so position would land them correctly today; what it would not survive is
640 /// the two lists living in different bodies, where the only thing holding
641 /// them in the same order is somebody reading both.
642 ///
643 /// # The description cell
644 ///
645 /// What the task is, and everything true of it that has no column of its
646 /// own. Subtask progress is the progress column's meter and is not repeated
647 /// here: one fact in two places can disagree only by being computed twice.
648 /// The started state is a badge, since a class is not a fact a description
649 /// can carry.
650 ///
651 /// Nothing here lives only in a `title`: a hover-only fact is one a touch or
652 /// keyboard user never sees, so it becomes a badge that says what it means.
653 ///
654 /// # The moves
655 ///
656 /// Reopening is the board's leftward drop, offered here because a completed
657 /// task is reachable through the status filter and a row with no move at all
658 /// is a dead row. See the module header for the eight moves the row does not
659 /// offer.
660 shape row_for(listing: &Listing, task: &Task) -> Row;
661
662 cells {
663 cell at "description" task.title.clone() {
664 activate to get "/tasks/{task.id}";
665
666 token Tag::badge("Started").tone(Tone::Info)
667 when task.status is TaskStatus::Started;
668
669 for marker in super::Availability::of(task).marker().into_iter() {
670 token marker;
671 }
672
673 token Tag::badge(commit_label(task)).tone(commit_tone(task))
674 when task.has_status_tokens();
675
676 for started in running_for(task).into_iter() {
677 since started;
678 }
679
680 for time in time_token(task).into_iter() {
681 token time;
682 }
683
684 token Tag::badge("Notes: {task.annotation_count()}") when task.has_annotations();
685
686 for contact in task.contact_name.iter() {
687 token Tag::badge(contact).tone(Tone::Neutral);
688 }
689
690 token Tag::badge("Snoozed").tone(Tone::Neutral) when task.is_snoozed();
691 token Tag::badge("Waiting").tone(waiting_tone(task)) when task.is_waiting();
692 }
693
694 cell at "project" task.project_name_or_dash();
695
696 // The single letter the shipped column shows, which is the whole cell.
697 // `as_str` is the right method here and only here: this is the
698 // one-character column it was written for.
699 cell at "priority" "" {
700 token Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority));
701 }
702
703 cell at "due" "" {
704 token Tag::badge(task.due_formatted()).tone(due_tone(task));
705 }
706
707 cell at "recurrence" recurrence_word(task);
708
709 cell at "progress" progress_dash(task) {
710 meter Meter::new(measured(task.subtasks_completed()), measured(task.subtask_count()))
711 .tone(Tone::Success)
712 when has_subtasks(task);
713 }
714
715 cell at "actions" "" {
716 act "Start"
717 to doing listing.view.carry(
718 Action::post("/tasks/list/{task.id}/status").with("status", "Started")
719 )
720 when task.status is TaskStatus::Pending;
721
722 act "Complete"
723 to doing listing.view.carry(
724 Action::post("/tasks/list/{task.id}/status").with("status", "Completed")
725 )
726 when can_complete(task);
727
728 act "Reopen"
729 to doing listing.view.carry(
730 Action::post("/tasks/list/{task.id}/status").with("status", "Pending")
731 )
732 when task.status is TaskStatus::Completed;
733
734 act "Delete"
735 to doing listing.view.carry(Action::post("/tasks/list/{task.id}/delete")) {
736 tone Danger;
737 confirm "Are you sure you want to delete this task? This cannot be undone.";
738 }
739 }
740
741 // The row joins the screen's selection under its own id, which is what
742 // the bulk bar acts on.
743 ticking task.id.to_string() listing.view.ticked;
744 }
745 }
746
747 /// The rows the view asks for, and how many there are in all.
748 fn page(state: &AppState, view: &View) -> Result<(Vec<Task>, i64), RouteError> {
749 state
750 .tasks
751 .list_filtered(DESKTOP_USER_ID, view.query())
752 .map_err(|error| RouteError::internal(error.to_string()))
753 }
754
755 /// Why the table has nothing in it.
756 ///
757 /// Three empty states, and the middle one is why this costs a second query: "no
758 /// pending tasks" and "no tasks at all" are different things to say, and only
759 /// the second one should offer a way to make the first.
760 enum Nothing {
761 /// There are rows.
762 Rows,
763 /// The filters matched nothing.
764 Filtered,
765 /// Nothing pending, but there are tasks.
766 AllClear,
767 /// No tasks have ever been made.
768 Never,
769 }
770
771 /// Everything the list draws, read once.
772 struct Listing {
773 view: View,
774 tasks: Vec<Task>,
775 /// How many match the view in all.
776 total: i64,
777 /// How many are on screen.
778 shown: i64,
779 /// The way to more of them, when there is an address that would show any.
780 more: Option<Rest>,
781 /// Why there are none, when there are none.
782 nothing: Nothing,
783 /// The projects the filter offers, and the ones the bulk picker does.
784 projects: Vec<Choice>,
785 bulk_projects: Vec<Choice>,
786 /// The milestones of the chosen project, if one is chosen.
787 milestones: Vec<Choice>,
788 /// The precomputed times the bulk snooze offers.
789 ///
790 /// The same ones the shipped modal offers, from the same function, so "Later
791 /// Today" means one thing in the app. A described screen has no modal to put
792 /// them in and does not need one: they are options.
793 whens: Vec<Choice>,
794 }
795
796 /// Read the list the view asks for.
797 fn read(state: &AppState, view: View) -> Result<Listing, RouteError> {
798 let (tasks, total) = page(state, &view)?;
799 let shown = i64::try_from(tasks.len()).unwrap_or(i64::MAX);
800 let next = (view.shown + PAGE).min(PAGE * PAGES);
801
802 // The table's own, since quasi 0.15.0. This was a `Node::Act` pushed after
803 // the table until then, because `Node::Table` carried no `Rest` and there
804 // was nowhere else to put it; what that cost was the renderer knowing the
805 // control belonged to the table above it.
806 let more = (shown < total && next > view.shown).then(|| {
807 Rest::more(
808 usize::try_from(shown).unwrap_or(usize::MAX),
809 view.showing(next).list(),
810 )
811 .of(usize::try_from(total).unwrap_or(usize::MAX))
812 });
813
814 let nothing = if !tasks.is_empty() {
815 Nothing::Rows
816 } else if view.filtered() {
817 Nothing::Filtered
818 } else {
819 let (_, ever) = page(
820 state,
821 &View {
822 status: None,
823 shown: 1,
824 ..View::default()
825 },
826 )?;
827 if ever > 0 {
828 Nothing::AllClear
829 } else {
830 Nothing::Never
831 }
832 };
833
834 let projects = state
835 .projects
836 .list_all(DESKTOP_USER_ID)
837 .map_err(|error| RouteError::internal(error.to_string()))?;
838
839 let milestones = match view.project {
840 Some(project) => state
841 .milestones
842 .list_by_project(project, DESKTOP_USER_ID)
843 .map_err(|error| RouteError::internal(error.to_string()))?,
844 None => Vec::new(),
845 };
846
847 Ok(Listing {
848 tasks,
849 total,
850 shown,
851 more,
852 nothing,
853 projects: if projects.is_empty() {
854 Vec::new()
855 } else {
856 std::iter::once(Choice::new("", "All projects"))
857 .chain(
858 projects
859 .iter()
860 .map(|project| Choice::new(project.id.to_string(), &project.name)),
861 )
862 .collect()
863 },
864 bulk_projects: std::iter::once(Choice::new(NONE, "No project"))
865 .chain(
866 projects
867 .iter()
868 .map(|project| Choice::new(project.id.to_string(), &project.name)),
869 )
870 .collect(),
871 milestones: if milestones.is_empty() {
872 Vec::new()
873 } else {
874 std::iter::once(Choice::new("", "All milestones"))
875 .chain(
876 milestones
877 .iter()
878 .map(|milestone| Choice::new(milestone.id.to_string(), &milestone.name)),
879 )
880 .collect()
881 },
882 whens: crate::commands::get_snooze_options()
883 .options
884 .into_iter()
885 .map(|option| Choice::new(option.time.to_rfc3339(), option.label))
886 .collect(),
887 view,
888 })
889 }
890
891 /// Whether the table is ordered by this column, ascending.
892 fn ascending(listing: &Listing, column: TaskSortColumn) -> bool {
893 listing.view.sort == column && listing.view.direction == SortDirection::Asc
894 }
895
896 /// Or descending.
897 fn descending(listing: &Listing, column: TaskSortColumn) -> bool {
898 listing.view.sort == column && listing.view.direction == SortDirection::Desc
899 }
900
901 /// Whether there are rows the address cannot reach.
902 ///
903 /// At `PAGE * PAGES` there is no address that would show anything new, so the
904 /// honest sentence is offered instead of a control that asks for the rows
905 /// already on screen. Narrowing is the way through a list this long, and the
906 /// filters are on the same screen.
907 fn at_ceiling(listing: &Listing) -> bool {
908 listing.shown < listing.total && listing.more.is_none()
909 }
910
911 /// What that sentence says.
912 fn ceiling(listing: &Listing) -> String {
913 format!(
914 "Showing {} of {}. Narrow the list to see the rest.",
915 listing.shown, listing.total
916 )
917 }
918
919 declare! {
920 /// The table, and the way to more of it.
921 ///
922 /// Seven columns, four of them sortable. The priority a column has when room
923 /// runs out is `build.rs`'s business, because it generates the narrowing CSS
924 /// from its own copy of the same seven, so only the width is stated here; a
925 /// renderer with no stylesheet reads it off the same order. Nothing checks
926 /// that the two agree, so add a column in both by hand.
927 ///
928 /// The table says there is more itself, through [`Table::more`].
929 shape table(listing: &Listing) -> Vec<Node>;
930
931 given listing.nothing {
932 Nothing::Filtered -> empty "No tasks match the current filters." {
933 offering "Clear filters" to doing View::default().list();
934 }
935 Nothing::AllClear -> empty "All clear. No pending tasks.";
936 Nothing::Never -> empty "No tasks yet.";
937 otherwise -> table {
938 column "description" {
939 width Fill;
940 reorder listing.view.sorted_by(TaskSortColumn::Description).list();
941 sorted Sort::Ascending when ascending(listing, TaskSortColumn::Description);
942 sorted Sort::Descending when descending(listing, TaskSortColumn::Description);
943 }
944 column "project" {
945 width Fixed;
946 reorder listing.view.sorted_by(TaskSortColumn::Project).list();
947 sorted Sort::Ascending when ascending(listing, TaskSortColumn::Project);
948 sorted Sort::Descending when descending(listing, TaskSortColumn::Project);
949 }
950 column "priority" {
951 width Fixed;
952 reorder listing.view.sorted_by(TaskSortColumn::Priority).list();
953 sorted Sort::Ascending when ascending(listing, TaskSortColumn::Priority);
954 sorted Sort::Descending when descending(listing, TaskSortColumn::Priority);
955 }
956 column "due" {
957 width Fixed;
958 reorder listing.view.sorted_by(TaskSortColumn::Due).list();
959 sorted Sort::Ascending when ascending(listing, TaskSortColumn::Due);
960 sorted Sort::Descending when descending(listing, TaskSortColumn::Due);
961 }
962 column "recurrence" { width Fixed; }
963 column "progress" { width Fixed; }
964 column "actions" { width Fixed; }
965
966 for task in listing.tasks.iter() {
967 include row_for(listing, task);
968 }
969
970 for rest in listing.more.iter() {
971 more rest.clone();
972 }
973 }
974 }
975
976 text ceiling(listing) when at_ceiling(listing);
977 }
978
979 declare! {
980 /// The controls over the selection.
981 ///
982 /// Five acts. Complete and Delete act on the set and carry no value;
983 /// priority, project and snooze apply a value, and reach it through
984 /// [`Act::asking`] rather than by being pickers that write as they change.
985 ///
986 /// Said as plain acts, a project picker is one button per project. Asking
987 /// answers that without the write, because the value rides with the press
988 /// instead of firing on its own, so a picker over forty projects is still
989 /// one control. Under wiki `explicit-commit-affordance` the press is also
990 /// the commit the reader needs, and a bar whose ticks stage while its
991 /// pickers write was half staged and half live.
992 ///
993 /// No blank leading option on the pickers. It existed because a bare select
994 /// opens on its first option, and one opening on "High" read as though the
995 /// selection already had a priority. A picker that is not on screen until
996 /// the verb is pressed cannot say that, so the resting state has nowhere to
997 /// be and the option that stood for it is gone.
998 ///
999 /// # What is not here
1000 ///
1001 /// The count. `tasks.js` writes "3 selected" into the bar and hides it when
1002 /// the selection is empty, and neither is sayable: the ticks are the host's
1003 /// until something submits them, so the description does not know how many
1004 /// there are. That is the right place for it -- a renderer knows exactly,
1005 /// and a webview one can count its own boxes -- and it is a gap in the
1006 /// renderers rather than in the vocabulary. The bar is always on screen
1007 /// here, which is the honest version of not knowing.
1008 ///
1009 /// Select-all is an address: see [`View::ticked`]. Its opposite is the same
1010 /// address without it, and only offered when there is something to clear.
1011 shape bulk(listing: &Listing) -> Vec<Node>;
1012
1013 act "Complete" to doing listing.view.carry(Action::post("/tasks/list/complete")) {
1014 over SELECTION;
1015 }
1016
1017 act "Delete" to doing listing.view.carry(Action::post("/tasks/list/delete")) {
1018 tone Danger;
1019 over SELECTION;
1020 confirm "Delete every selected task? This cannot be undone.";
1021 }
1022
1023 act "Select all" to doing listing.view.ticking(true).list();
1024 act "Clear selection" to doing listing.view.ticking(false).list()
1025 when listing.view.ticked;
1026
1027 act "Set priority" to doing listing.view.carry(Action::post("/tasks/list/priority")) {
1028 over SELECTION;
1029 field Select "priority" "Priority" {
1030 for offered in PRIORITIES {
1031 option Choice::new(priority_word(&offered), priority_word(&offered));
1032 }
1033 }
1034 }
1035
1036 act "Set project" to doing listing.view.carry(Action::post("/tasks/list/project")) {
1037 over SELECTION;
1038 field Select "project" "Project" {
1039 options listing.bulk_projects.clone();
1040 }
1041 }
1042
1043 act "Snooze until" to doing listing.view.carry(Action::post("/tasks/list/snooze")) {
1044 over SELECTION;
1045 field Select "until" "Snooze until" {
1046 options listing.whens.clone();
1047 }
1048 }
1049 }
1050
1051 /// What a status filter chip reads.
1052 fn status_word(status: Option<&TaskStatus>) -> &'static str {
1053 status.map_or("All statuses", TaskStatus::as_str)
1054 }
1055
1056 /// Whether that chip is the one in force.
1057 fn is_status(listing: &Listing, status: Option<&TaskStatus>) -> bool {
1058 listing.view.status.as_ref() == status
1059 }
1060
1061 /// Whether that priority chip is.
1062 fn is_priority(listing: &Listing, priority: &Priority) -> bool {
1063 listing.view.priority.as_ref() == Some(priority)
1064 }
1065
1066 /// The project the filter is on, if it is on one.
1067 fn project_value(listing: &Listing) -> Option<String> {
1068 listing.view.project.map(|project| project.to_string())
1069 }
1070
1071 /// The milestone the filter is on, if it is on one.
1072 fn milestone_value(listing: &Listing) -> Option<String> {
1073 listing
1074 .view
1075 .milestone
1076 .map(|milestone| milestone.to_string())
1077 }
1078
1079 declare! {
1080 /// The filter controls.
1081 ///
1082 /// The two long lists are [`Field::select`] with a
1083 /// [`consulting`](Field::consulting), for the reason the mail screen gives:
1084 /// the project set is whatever the user has and can be any length, and a
1085 /// strip of options is a shape for a handful. The short ones are chips,
1086 /// latched, which is what the problems inbox settled on.
1087 ///
1088 /// The milestone control appears only under a chosen project, which is
1089 /// `populateMilestoneFilter`'s rule: a milestone belongs to a project, so
1090 /// offering every project's milestones at once would be a control whose
1091 /// options mean nothing together.
1092 shape filters(listing: &Listing) -> Vec<Node>;
1093
1094 for offered in STATUSES.iter() {
1095 chip status_word(offered.as_ref())
1096 to doing listing.view.with_status(offered.clone()).list() {
1097 latched is_status(listing, offered.as_ref());
1098 }
1099 }
1100
1101 field Select "project" "Project" unless listing.projects.is_empty() {
1102 options listing.projects.clone();
1103 consulting Consult::at_once(listing.view.clearing_project().list());
1104 for project in project_value(listing).into_iter() {
1105 value project;
1106 }
1107 }
1108
1109 field Select "milestone" "Milestone" unless listing.milestones.is_empty() {
1110 options listing.milestones.clone();
1111 consulting Consult::at_once(listing.view.clearing_milestone().list());
1112 for milestone in milestone_value(listing).into_iter() {
1113 value milestone;
1114 }
1115 }
1116
1117 for offered in PRIORITIES {
1118 chip priority_word(&offered)
1119 to doing listing.view.toggling_priority(&offered).list() {
1120 latched is_priority(listing, &offered);
1121 }
1122 }
1123
1124 chip "Include snoozed" to doing listing.view.toggling_snoozed().list() {
1125 latched listing.view.snoozed;
1126 }
1127
1128 chip "Waiting only" to doing listing.view.toggling_waiting().list() {
1129 latched listing.view.waiting;
1130 }
1131
1132 act "Clear filters" to doing View::default().list() when listing.view.filtered();
1133 }
1134
1135 /// How the band counts what the view matched.
1136 fn counted_tasks(listing: &Listing) -> String {
1137 listing.total.to_string()
1138 }
1139
1140 /// And what it calls them.
1141 fn task_noun(listing: &Listing) -> &'static str {
1142 if listing.total == 1 { "task" } else { "tasks" }
1143 }
1144
1145 declare! {
1146 /// The whole screen.
1147 ///
1148 /// The count is the one the shipped screen puts in a chip. "X of N" is the
1149 /// table's to say, because the table is what knows how many rows it drew.
1150 shape screen(listing: &Listing) -> Screen;
1151
1152 screen list_detail "Tasks" false {
1153 at_place super::shell::TASKS;
1154 selecting SELECTION;
1155
1156 region "tasks-band" as Band {
1157 page "Tasks";
1158
1159 stats [] {
1160 figure Figure::new(counted_tasks(listing), task_noun(listing));
1161 }
1162
1163 extend filters(listing);
1164 }
1165
1166 region "tasks-bulk" as Band {
1167 extend bulk(listing);
1168 }
1169
1170 region "tasks-list" as Pane {
1171 extend table(listing);
1172 }
1173 }
1174 }
1175
1176 /// The whole screen, as an answer.
1177 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1178 Ok(screen(&read(state, View::of(&request)?)?).into())
1179 }
1180
1181 /// The list, and the bar over it, as one answer.
1182 ///
1183 /// Two regions rather than one because they are two regions: the bar sits above
1184 /// the filters' output and outlives a page of it. They travel together on every
1185 /// answer because the bar changes with the view -- "Clear selection" is on it
1186 /// only when something is ticked -- and an answer moving one without the other
1187 /// would leave a bar offering to clear a selection the rows no longer have.
1188 fn answer(state: &AppState, view: &View) -> Result<Response, RouteError> {
1189 let listing = read(state, view.clone())?;
1190 Ok(Response::fragment(
1191 "tasks-list",
1192 Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(&listing))),
1193 )
1194 .also(
1195 "tasks-bulk",
1196 Node::Region(Slot::new("tasks-bulk", RegionKind::Band).extend(bulk(&listing))),
1197 ))
1198 }
1199
1200 /// The table alone, which is what a filter, a sort or "show more" swaps.
1201 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1202 answer(state, &View::of(&request)?)
1203 }
1204
1205 /// The task a route was addressed at.
1206 fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
1207 let raw = request
1208 .captures
1209 .get("id")
1210 .ok_or_else(|| RouteError::not_found("no task id"))?;
1211 Ok(TaskId::from(
1212 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
1213 ))
1214 }
1215
1216 /// Read a task the list is acting on, or answer 404.
1217 fn load(state: &AppState, id: TaskId) -> Result<Task, RouteError> {
1218 state
1219 .tasks
1220 .get_by_id(id, DESKTOP_USER_ID)
1221 .map_err(|error| RouteError::internal(error.to_string()))?
1222 .filter(|task| task.status != TaskStatus::Deleted)
1223 .ok_or_else(|| RouteError::not_found("no such task"))
1224 }
1225
1226 /// Answer a write with the list it happened in, re-read.
1227 ///
1228 /// Re-read rather than patched, for the reason the problems inbox gives: the
1229 /// row usually leaves the list it was in, since the filter is `Pending` by
1230 /// default and the press is what settles it. Completing a recurring task also
1231 /// mints its successor, which the list has no way to know about without asking.
1232 fn wrote(
1233 state: &AppState,
1234 request: &quasi_router::Request,
1235 message: impl Into<String>,
1236 ) -> Result<Response, RouteError> {
1237 // The ticks are cleared by answering a view that has none. A bulk write
1238 // whose answer re-ticked every surviving row would leave "everything"
1239 // meaning something new after every press, which is `tasks.js`'s rule
1240 // (`selectedTaskIds.clear()` in each of its five bulk paths) arrived at
1241 // from the other side.
1242 let view = View {
1243 ticked: false,
1244 ..View::of(request)?
1245 };
1246 Ok(answer(state, &view)?.toast(Tone::Success, message))
1247 }
1248
1249 /// Start a task, complete it, or send it back to Pending.
1250 ///
1251 /// The target is a param, never derived from what the row was drawn with, so two
1252 /// windows on the same list cannot disagree about what the next state was. It
1253 /// travels as `status` and the filter is also `status`, which is safe because
1254 /// `payload` and `carried` are different bags — the arrangement the problems
1255 /// inbox and the mail screen paid for on the same afternoon.
1256 fn set_status(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1257 let task = load(state, task_id(&request)?)?;
1258 let to = match request.payload.get("status") {
1259 Some("Pending") => TaskStatus::Pending,
1260 Some("Started") => TaskStatus::Started,
1261 Some("Completed") => TaskStatus::Completed,
1262 _ => return Err(RouteError::not_found("not a status a control can set")),
1263 };
1264
1265 // Already there. A repeated post must not complete a task twice and mint a
1266 // second recurrence, which is the board's reasoning and matters more here:
1267 // a row can be pressed while the list it was drawn in is stale.
1268 if task.status == to {
1269 return answer(state, &View::of(&request)?);
1270 }
1271
1272 let message = super::move_to(state, &task, &to)?;
1273 wrote(state, &request, message)
1274 }
1275
1276 /// Delete a task, and answer the list it left.
1277 ///
1278 /// A fragment rather than the drawer's redirect: this row was already on the
1279 /// list, so there is nowhere to send anyone.
1280 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1281 let id = task_id(&request)?;
1282 let deleted = state
1283 .tasks
1284 .delete(id, DESKTOP_USER_ID)
1285 .map_err(|error| RouteError::internal(error.to_string()))?;
1286 if !deleted {
1287 return Err(RouteError::not_found("no such task"));
1288 }
1289 wrote(state, &request, "Task deleted.")
1290 }
1291
1292 /// Every task the user ticked, in the order they arrived.
1293 ///
1294 /// The ticks come back under one repeated name rather than a joined string,
1295 /// which is what [`quasi_router::Params::get_all`] is for and why no delimiter
1296 /// had to be one no id can contain.
1297 ///
1298 /// An id that does not parse is dropped rather than refused. A bulk write is
1299 /// answered by the list it happened in, and failing the whole press because one
1300 /// value was malformed would lose the other thirty-nine; the count in the toast
1301 /// is what the user actually gets, so a silent drop still shows up as a smaller
1302 /// number. An empty set is not an error either: it answers the unchanged list
1303 /// with nothing said.
1304 fn chosen(request: &quasi_router::Request) -> Vec<TaskId> {
1305 request
1306 .payload
1307 .get_all(quasi_router::Node::TICKED)
1308 .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok())
1309 .map(TaskId::from)
1310 .collect()
1311 }
1312
1313 /// `N tasks` or `1 task`, for a toast that counts.
1314 fn counted(n: usize) -> String {
1315 if n == 1 {
1316 "1 task".to_owned()
1317 } else {
1318 format!("{n} tasks")
1319 }
1320 }
1321
1322 /// Complete every ticked task.
1323 ///
1324 /// One at a time through [`super::move_to`], which is the same path a row's own
1325 /// Complete takes: each one may mint a recurring successor, stop a timer and
1326 /// close a milestone, and a bulk loop that skipped any of that would be a second
1327 /// meaning of the word.
1328 ///
1329 /// A task that has moved since the list was drawn is skipped rather than
1330 /// failing the press.
1331 fn complete_chosen(
1332 state: &AppState,
1333 request: quasi_router::Request,
1334 ) -> Result<Response, RouteError> {
1335 let mut done = 0;
1336 for id in chosen(&request) {
1337 let Ok(task) = load(state, id) else { continue };
1338 if task.status == TaskStatus::Completed {
1339 continue;
1340 }
1341 if super::move_to(state, &task, &TaskStatus::Completed).is_ok() {
1342 done += 1;
1343 }
1344 }
1345 wrote(state, &request, format!("{} completed.", counted(done)))
1346 }
1347
1348 /// Delete every ticked task.
1349 fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1350 let mut done = 0;
1351 for id in chosen(&request) {
1352 if state
1353 .tasks
1354 .delete(id, DESKTOP_USER_ID)
1355 .map_err(|error| RouteError::internal(error.to_string()))?
1356 {
1357 done += 1;
1358 }
1359 }
1360 wrote(state, &request, format!("{} deleted.", counted(done)))
1361 }
1362
1363 /// Set the priority on every ticked task.
1364 ///
1365 /// One transaction, through the repository's own bulk write, which also
1366 /// recomputes urgency because priority is an input to it. The blank option is
1367 /// the control's resting state and writes nothing.
1368 fn priority_chosen(
1369 state: &AppState,
1370 request: quasi_router::Request,
1371 ) -> Result<Response, RouteError> {
1372 let Some(word) = text(&request.payload, "priority") else {
1373 return answer(state, &View::of(&request)?);
1374 };
1375 let priority = match word.as_str() {
1376 "High" => Priority::High,
1377 "Medium" => Priority::Medium,
1378 "Low" => Priority::Low,
1379 _ => return Err(RouteError::not_found("not a priority")),
1380 };
1381
1382 let ids = chosen(&request);
1383 let done = state
1384 .tasks
1385 .bulk_set_priority(DESKTOP_USER_ID, &ids, priority.clone())
1386 .map_err(|error| RouteError::internal(error.to_string()))?;
1387
1388 wrote(
1389 state,
1390 &request,
1391 format!("{} set to {}.", counted(done), priority_word(&priority)),
1392 )
1393 }
1394
1395 /// Move every ticked task to a project, or out of one.
1396 fn project_chosen(
1397 state: &AppState,
1398 request: quasi_router::Request,
1399 ) -> Result<Response, RouteError> {
1400 let Some(raw) = text(&request.payload, "project") else {
1401 return answer(state, &View::of(&request)?);
1402 };
1403 // "No project" is a word rather than a blank, because a blank is the
1404 // control saying nothing happened.
1405 let project = if raw == NONE {
1406 None
1407 } else {
1408 Some(ProjectId::from(
1409 uuid::Uuid::parse_str(&raw).map_err(|_| RouteError::not_found("not a project id"))?,
1410 ))
1411 };
1412
1413 let ids = chosen(&request);
1414 let done = state
1415 .tasks
1416 .bulk_set_project(DESKTOP_USER_ID, &ids, project)
1417 .map_err(|error| RouteError::internal(error.to_string()))?;
1418
1419 wrote(
1420 state,
1421 &request,
1422 match project {
1423 Some(_) => format!("{} moved.", counted(done)),
1424 None => format!("{} taken out of their project.", counted(done)),
1425 },
1426 )
1427 }
1428
1429 /// Snooze every ticked task until a time the user picked.
1430 ///
1431 /// No bulk repository write for this one, so it is a loop. The times come from
1432 /// the same function the rest of the app uses, so "Later Today" means one thing
1433 /// everywhere.
1434 fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1435 let Some(raw) = text(&request.payload, "until") else {
1436 return answer(state, &View::of(&request)?);
1437 };
1438 let until = chrono::DateTime::parse_from_rfc3339(&raw)
1439 .map_err(|_| RouteError::not_found("not a time"))?
1440 .with_timezone(&chrono::Utc);
1441
1442 let mut done = 0;
1443 for id in chosen(&request) {
1444 if state
1445 .tasks
1446 .snooze(id, DESKTOP_USER_ID, until)
1447 .map_err(|error| RouteError::internal(error.to_string()))?
1448 .is_some()
1449 {
1450 done += 1;
1451 }
1452 }
1453
1454 wrote(state, &request, format!("{} snoozed.", counted(done)))
1455 }
1456
1457 /// The task list's routes.
1458 #[must_use]
1459 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1460 router
1461 .get("/tasks", index)
1462 .get("/tasks/list", list)
1463 .post("/tasks/list/complete", complete_chosen)
1464 .post("/tasks/list/delete", delete_chosen)
1465 .post("/tasks/list/priority", priority_chosen)
1466 .post("/tasks/list/project", project_chosen)
1467 .post("/tasks/list/snooze", snooze_chosen)
1468 .post("/tasks/list/{id}/status", set_status)
1469 .post("/tasks/list/{id}/delete", remove)
1470 }
1471