Skip to main content

max / goingson

51.6 KB · 1421 lines History Blame Raw
1 //! The task overview, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The completion heatmap is a month grid of counts, and a description
6 //! expressive enough to produce one is a widget library wearing a description's
7 //! name. It gets a [`RegionKind::Ceded`] and stops there: nothing is owed, so
8 //! a renderer with no fill for it draws nothing and is right to.
9 //!
10 //! # The shape
11 //!
12 //! - `GET /tasks/{id}` — the whole overview.
13 //! - `GET /tasks/{id}/edit` — the edit form, which is a screen of its own.
14 //! - `POST /tasks/{id}` — save it.
15 //! - `POST /tasks/{id}/complete` — mark it done.
16 //! - `POST /tasks/{id}/delete` — delete it.
17 //! - `POST /tasks/{id}/subtasks` — add one.
18 //! - `POST /tasks/{id}/subtasks/{sub}/toggle` — tick or untick one.
19 //! - `POST /tasks/{id}/notes` — add a note.
20 //! - `POST /tasks/{id}/blockers` — draw an edge, carrying `blocker`.
21 //! - `POST /tasks/{id}/dependencies/{other}/remove` — cut one, carrying `role`.
22 //!
23 //! Every described control reaches one of those.
24 //!
25 //! Edit is an address rather than an arrangement: the form is [`edit_screen`],
26 //! and the overview links to it. The two things it cannot ask for are recorded
27 //! on [`edit_fields`].
28
29 // Handlers take their request by value because `quasi_router::Handler` is a
30 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
31 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
32 #![allow(clippy::needless_pass_by_value)]
33
34 use chrono::{DateTime, Local, Utc};
35 use goingson_core::{
36 Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId,
37 TaskStatus, TimeSession, UpdateTask,
38 };
39 use quasi_declare::declare;
40 use quasi_router::screen::{Choice, Figure, Tag};
41 use quasi_router::{Action, RegionKind, Response, RouteError, Router};
42
43 use super::parse_optional_id;
44 use crate::commands::{StreakInfo, compute_streak};
45 use crate::state::{AppState, DESKTOP_USER_ID};
46
47 #[cfg(test)]
48 mod tests;
49
50 /// The tone a status badge wears.
51 ///
52 /// Green, blue and muted across the three live statuses. `Deleted` never
53 /// reaches a rendered screen and takes the same neutral as pending rather than
54 /// a tone of its own.
55 const fn status_tone(status: &TaskStatus) -> makeover_layout::Tone {
56 match status {
57 TaskStatus::Completed => makeover_layout::Tone::Success,
58 TaskStatus::Started => makeover_layout::Tone::Info,
59 TaskStatus::Pending | TaskStatus::Deleted => makeover_layout::Tone::Neutral,
60 }
61 }
62
63 /// The tone a priority badge wears.
64 ///
65 /// Red, yellow and muted. Low is neutral rather than a cool colour for the
66 /// reason `Tone`'s own docs give: a tone on everything is a tone on nothing.
67 const fn priority_tone(priority: &Priority) -> makeover_layout::Tone {
68 match priority {
69 Priority::High => makeover_layout::Tone::Danger,
70 Priority::Medium => makeover_layout::Tone::Warning,
71 Priority::Low => makeover_layout::Tone::Neutral,
72 }
73 }
74
75 /// A short local date, the way every list on this screen writes one.
76 ///
77 /// One function rather than a format string per call site, because three call
78 /// sites that format a date three ways is how a screen ends up looking
79 /// assembled.
80 fn short_date(at: DateTime<Utc>) -> String {
81 at.with_timezone(&Local).format("%b %-d").to_string()
82 }
83
84 /// The task a route was addressed at.
85 fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
86 let raw = request
87 .captures
88 .get("id")
89 .ok_or_else(|| RouteError::not_found("no task id"))?;
90 Ok(TaskId::from(
91 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
92 ))
93 }
94
95 /// Read the task, or answer 404.
96 ///
97 /// A deleted task is a 404 here even though `get_by_id` still returns it.
98 /// `TaskCrud::delete` is a soft delete — it sets the status and `list_all`
99 /// filters the row out — so a screen addressing one by id would otherwise
100 /// render a deleted task as an ordinary one, complete with controls offering to
101 /// complete it. The JS never met this because it closes the drawer on delete
102 /// and does not re-fetch. An address that outlives the thing it addresses is
103 /// exactly what a router has to answer for.
104 fn load(state: &AppState, id: TaskId) -> Result<Task, RouteError> {
105 let task = state
106 .tasks
107 .get_by_id(id, DESKTOP_USER_ID)
108 .map_err(|error| RouteError::internal(error.to_string()))?
109 .filter(|task| task.status != TaskStatus::Deleted)
110 .ok_or_else(|| RouteError::not_found("no such task"))?;
111 Ok(task)
112 }
113
114 /// The streak stats, for a task that has a recurrence chain.
115 ///
116 /// `None` for a one-off, which is what makes the whole completion-history
117 /// section absent rather than empty. Reuses `compute_streak` from the command
118 /// layer rather than restating it: the streak table is already Rust, and two
119 /// copies of a rule that decides what a number means is worse than an import
120 /// across module lines.
121 fn streak_for(state: &AppState, task: &Task) -> Result<Option<StreakInfo>, RouteError> {
122 if !task.has_recurrence() && task.recurrence_parent_id.is_none() {
123 return Ok(None);
124 }
125 let root = task.recurrence_parent_id.unwrap_or(task.id);
126 let chain = state
127 .tasks
128 .list_recurrence_chain(root, DESKTOP_USER_ID)
129 .map_err(|error| RouteError::internal(error.to_string()))?;
130 Ok(Some(compute_streak(&chain)))
131 }
132
133 declare! {
134 /// The four streak figures, as one strip.
135 ///
136 /// A large value over a small caption, laid out as a strip: `Figure` plus
137 /// `Node::Stats`. The set is the node rather than each figure, because four
138 /// tiles in a strip and four down a column are different things and a
139 /// renderer handed one at a time cannot tell which it is looking at.
140 ///
141 /// None of these four answers a click. `Node::Stats` carries an optional
142 /// action per figure for the one goingson site that does -- sync's "Not
143 /// Applied: 3" -- and this section does not use it.
144 shape habit_figures(streak: &StreakInfo) -> Node;
145
146 stats [] {
147 figure Figure::new("{streak.current_streak}d", "Current Streak");
148 figure Figure::new("{streak.best_streak}d", "Best Streak");
149 figure Figure::new("{completion_rate(streak)}%", "Completion Rate");
150 figure Figure::new(
151 "{streak.total_completed}/{streak.total_instances}",
152 "Total Completed"
153 );
154 }
155 }
156
157 /// The thirty-day rate, rounded the way the strip shows it.
158 fn completion_rate(streak: &StreakInfo) -> i64 {
159 streak.completion_rate_30d.round() as i64
160 }
161
162 declare! {
163 /// The completion-history section, for a recurring task.
164 shape habit_section(streak: &StreakInfo, task: TaskId) -> Vec<Node>;
165
166 section "Completion History";
167 include habit_figures(streak);
168
169 // The heatmap. A month grid of completion counts is exactly what
170 // `RegionKind::Ceded` was named for: the description says a thing called
171 // `task-heatmap` goes here and says nothing else, and
172 // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a gap
173 // -- a description able to produce a calendar grid is a widget library with
174 // a description's name on it.
175 //
176 // The id carries the task so the filling code knows which chain to render
177 // without asking the screen.
178 region "task-heatmap-{task}" as RegionKind::ceded("task-heatmap") {}
179 }
180
181 declare! {
182 /// The badges across the top: status, priority, and whatever else is true.
183 ///
184 /// Tokens rather than text, so a status keeps its tone. The three
185 /// conditional ones come in the order focus, overdue, snoozed.
186 shape badges(task: &Task) -> Vec<Node>;
187
188 badge task.status.as_str() {
189 tone status_tone(&task.status);
190 }
191
192 badge task.priority.as_str() {
193 tone priority_tone(&task.priority);
194 }
195
196 badge "Focus" when task.is_focus {
197 tone Info;
198 }
199
200 badge "Overdue" when task.is_overdue() {
201 tone Danger;
202 }
203
204 badge "Snoozed" when task.is_snoozed() {
205 tone Warning;
206 }
207 }
208
209 declare! {
210 /// The metadata section.
211 ///
212 /// A task's description is markdown, and it goes in as `rich`, which
213 /// carries the markdown **source** rather than markup. Every renderer
214 /// renders that source its own way, and nothing in a description is ever
215 /// markup, so `Node::Text`'s escaping guarantee is untouched.
216 ///
217 /// quasi-webview renders it through docengine's strict preset, so raw HTML
218 /// inside a description reads as text. A shared renderer taking what a user
219 /// typed should be the safer of the two.
220 ///
221 /// # The labelled facts under the badges
222 ///
223 /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the
224 /// label and whose meta is the value. The JS bolds the label inside a
225 /// sentence; a row is the nearest thing the vocabulary has to a definition
226 /// list, and unlike the stats above the label really is the primary here.
227 shape metadata(task: &Task) -> Vec<Node>;
228
229 extend badges(task);
230
231 rich &task.description unless task.description.is_empty();
232
233 list {
234 for project in task.project_name.iter() {
235 row "Project" {
236 meta project;
237 }
238 }
239
240 row "Due" when task.due.is_some() {
241 meta task.due_formatted();
242 }
243
244 row "Recurrence" when task.has_recurrence() {
245 meta task.recurrence.as_str();
246 }
247
248 for contact in task.contact_name.iter() {
249 row "Contact" {
250 meta contact;
251 }
252 }
253
254 // Tokens, because a tag is a badge in the JS and `RowPart::Tokens`
255 // exists now to keep it one.
256 row "Tags" unless task.tags.is_empty() {
257 for tag in task.tags.iter() {
258 token Tag::badge(tag);
259 }
260 }
261 } unless no_details(task);
262 }
263
264 /// Whether the task has any labelled fact worth a row.
265 fn no_details(task: &Task) -> bool {
266 task.project_name.is_none()
267 && task.due.is_none()
268 && !task.has_recurrence()
269 && task.contact_name.is_none()
270 && task.tags.is_empty()
271 }
272
273 declare! {
274 /// One subtask.
275 ///
276 /// # The tick is the write
277 ///
278 /// **A tick that means something has no route.** `Row::selected` said
279 /// whether a row is ticked and whether it can be, and nothing said what
280 /// ticking it *calls*. That was right for the case it was added for --
281 /// goingson's bulk-selection checkboxes are client state feeding a later
282 /// bulk action -- and a subtask is the other case: the tick is the write.
283 ///
284 /// `Row::toggle` lives on quasi-router rather than makeover-layout: the
285 /// vocabulary has no notion of an action, so "what this calls" is not a
286 /// thing it can say. A standalone control uses `Field::writes` instead.
287 ///
288 /// A linked subtask keeps the button: its state follows the task it links
289 /// to, so it is not tickable at all, and a disabled act is what says that.
290 /// Describing it as a tick that refuses to move would be the same defect
291 /// the other way round.
292 shape subtask_row(task: TaskId, subtask: &Subtask) -> Row;
293
294 row &subtask.text {
295 toggling subtask.is_completed
296 Action::post("/tasks/{task}/subtasks/{subtask.id}/toggle")
297 unless is_linked(subtask);
298
299 selectable subtask.is_completed when is_linked(subtask);
300 token Tag::badge("Linked") when is_linked(subtask);
301
302 act undo_or_done(subtask)
303 to post "/tasks/{task}/subtasks/{subtask.id}/toggle"
304 when is_linked(subtask) {
305 disabled;
306 }
307 }
308 }
309
310 /// Whether the subtask's state follows a task it links to.
311 fn is_linked(subtask: &Subtask) -> bool {
312 subtask.linked_task_id.is_some()
313 }
314
315 /// What the linked subtask's refused button reads.
316 fn undo_or_done(subtask: &Subtask) -> &'static str {
317 if subtask.is_completed { "Undo" } else { "Done" }
318 }
319
320 declare! {
321 /// The subtasks section.
322 ///
323 /// # A heading cannot carry a count, and a proportion is a `Meter`
324 ///
325 /// The count rides in the heading text; the proportion is a `proportion`
326 /// member. This section is the first consumer -- the bar comes back, with
327 /// the count still in the heading because that half is a nice-to-have and
328 /// stays one. `d0b58239`.
329 ///
330 /// The count is deliberately not moved into the meter's label. The
331 /// heading's count names the section and reads without the bar; the meter's
332 /// label names what is being counted. Saying "3/7" twice would be the same
333 /// fact in two places, which is what the concatenation was.
334 shape subtasks_section(task: &Task) -> Vec<Node>;
335
336 section "Subtasks {task.subtasks_completed()}/{task.subtask_count()}";
337
338 // Success, matching `tasks-render.js` and the four other subtask rollups.
339 // Completion is the one proportion here that cannot mean anything bad.
340 proportion counted(task.subtasks_completed()) counted(task.subtask_count())
341 unless task.subtasks.is_empty() {
342 tone Success;
343 label "subtasks";
344 }
345
346 list {
347 for subtask in task.subtasks.iter() {
348 include subtask_row(task.id, subtask);
349 }
350 } unless task.subtasks.is_empty();
351
352 form post "/tasks/{task.id}/subtasks" {
353 submit "Add";
354
355 field Text "text" "Subtask" {
356 required;
357 placeholder "Add subtask...";
358 }
359 }
360 }
361
362 /// A count as a meter reads one.
363 fn counted(n: usize) -> u32 {
364 u32::try_from(n).unwrap_or(u32::MAX)
365 }
366
367 declare! {
368 /// One tracked session.
369 shape session_row(session: &TimeSession) -> Row;
370
371 row when_started(session) {
372 // A session with no end is one running right now, which is a fact about
373 // the task and not a missing value.
374 meta ran_for(session);
375 }
376 }
377
378 /// When a session started, as the row reads it.
379 fn when_started(session: &TimeSession) -> String {
380 format!(
381 "{} {}",
382 short_date(session.started_at),
383 session.started_at.with_timezone(&Local).format("%-I:%M %p")
384 )
385 }
386
387 /// How long it ran, or that it still is.
388 fn ran_for(session: &TimeSession) -> String {
389 session
390 .duration_minutes
391 .map_or_else(|| "active".to_owned(), |minutes| format!("{minutes}m"))
392 }
393
394 declare! {
395 /// The time-tracking section.
396 ///
397 /// The site that decided `Meter`'s shape. This bar is toned, red past the
398 /// estimate and green under it, and it is the one place in either app where
399 /// the numerator can exceed the denominator, which is why the member
400 /// carries the pair and not the percentage `Task::time_progress` computes:
401 /// that function clamps to 100 and the over-run survives only in the
402 /// separate `is_over_estimate` flag beside it.
403 ///
404 /// So the heading keeps the readable summary and the meter carries the
405 /// numbers unclamped. ", over" stays in the heading text: the meter says it
406 /// to a renderer through `data-over`, and the heading says it to someone
407 /// reading.
408 ///
409 /// Track Time is the shipped drawer's modal button and is the one control
410 /// that starts a timer anywhere in the described app. Stopping is not
411 /// offered beside it: a running timer is the chrome panel's, and a second
412 /// Stop here would be a second answer to what stopping means. See
413 /// [`super::time_tracking`].
414 shape time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node>;
415
416 section "Time Tracking {time_label(task)}";
417
418 // The tone `Meter` refuses to derive: the same fullness is success on the
419 // subtask bar above and danger here.
420 proportion counted_i32(task.actual_minutes) counted_i32(estimate(task))
421 when estimate(task) over 0 {
422 tone over_estimate(task);
423 label "minutes";
424 }
425
426 list {
427 for session in sessions.iter() {
428 include session_row(session);
429 }
430 } unless sessions.is_empty();
431
432 act "Track time" to post "/timer/start" with "task" task.id.to_string()
433 unless task.has_active_timer();
434 }
435
436 /// The estimate, or zero where there is none.
437 fn estimate(task: &Task) -> i32 {
438 task.estimated_minutes.unwrap_or(0)
439 }
440
441 /// The readable summary in the heading.
442 fn time_label(task: &Task) -> String {
443 let tracked = format!("{}m tracked", task.actual_minutes);
444 if estimate(task) > 0 {
445 let over = if task.is_over_estimate() {
446 ", over"
447 } else {
448 ""
449 };
450 format!("{tracked} / {}m est{over}", estimate(task))
451 } else {
452 tracked
453 }
454 }
455
456 /// Which way the bar reads.
457 fn over_estimate(task: &Task) -> makeover_layout::Tone {
458 if task.is_over_estimate() {
459 makeover_layout::Tone::Danger
460 } else {
461 makeover_layout::Tone::Success
462 }
463 }
464
465 /// A count of minutes as a meter reads one.
466 fn counted_i32(minutes: i32) -> u32 {
467 u32::try_from(minutes).unwrap_or(u32::MAX)
468 }
469
470 declare! {
471 /// One note.
472 shape annotation_row(annotation: &Annotation) -> Row;
473
474 row &annotation.note {
475 meta noted_at(annotation);
476 }
477 }
478
479 /// When a note was written, as the row reads it.
480 fn noted_at(annotation: &Annotation) -> String {
481 format!(
482 "{} {}",
483 short_date(annotation.timestamp),
484 annotation
485 .timestamp
486 .with_timezone(&Local)
487 .format("%-I:%M %p")
488 )
489 }
490
491 declare! {
492 /// The notes section.
493 shape notes_section(task: &Task) -> Vec<Node>;
494
495 section "Notes {task.annotations.len()}";
496
497 list {
498 for annotation in task.annotations.iter() {
499 include annotation_row(annotation);
500 }
501 } unless task.annotations.is_empty();
502
503 form post "/tasks/{task.id}/notes" {
504 submit "Add";
505
506 field Text "note" "Note" {
507 required;
508 placeholder "Add note...";
509 }
510 }
511 }
512
513 /// Which end of an edge a row is looking at.
514 ///
515 /// Removing is always expressed as `(blocked, blocker)` regardless of which
516 /// list the user is reading, so the row has to say which side it is on. It
517 /// travels as a payload value rather than as two ids in the address, because
518 /// the address names the task whose screen answers and that is the viewed task
519 /// either way.
520 #[derive(Clone, Copy, PartialEq, Eq)]
521 enum Role {
522 /// The other task blocks this one.
523 Blocker,
524 /// The other task waits on this one.
525 Dependent,
526 }
527
528 impl Role {
529 const fn as_str(self) -> &'static str {
530 match self {
531 Self::Blocker => "blocker",
532 Self::Dependent => "dependent",
533 }
534 }
535
536 fn from_payload(raw: &str) -> Result<Self, RouteError> {
537 match raw {
538 "blocker" => Ok(Self::Blocker),
539 "dependent" => Ok(Self::Dependent),
540 _ => Err(RouteError::not_found("no such side of an edge")),
541 }
542 }
543 }
544
545 declare! {
546 /// One end of an edge.
547 ///
548 /// Satisfied edges are drawn, greyed rather than hidden: a completed
549 /// blocker is the record of what this task waited for, and dropping it
550 /// would make a finished chain look like it never existed. The greying is a
551 /// tone, because "satisfied" is a fact about the edge and `.is-satisfied`
552 /// is one host's way of drawing it.
553 shape dependency_row(viewed: TaskId, entry: &LinkedTaskRef, role: Role) -> Row;
554
555 row &entry.title {
556 token Tag::badge(entry.status.as_str()).tone(edge_tone(entry));
557
558 for project in entry.project_name.iter() {
559 meta project;
560 }
561
562 act "Remove"
563 to post "/tasks/{viewed}/dependencies/{entry.id}/remove"
564 with "role" role.as_str();
565
566 activate to get "/tasks/{entry.id}";
567 }
568 }
569
570 /// How an edge's status badge reads: neutral once it is satisfied, because a
571 /// finished blocker is history rather than a state to act on.
572 fn edge_tone(entry: &LinkedTaskRef) -> makeover_layout::Tone {
573 if entry.is_satisfied() {
574 makeover_layout::Tone::Neutral
575 } else {
576 status_tone(&entry.status)
577 }
578 }
579
580 /// Where the task sits in the graph, said once and plainly.
581 ///
582 /// Unlike the row markers on the board and the dashboard, this section says
583 /// "Ready" out loud: it is the screen the reader came to for the answer, so
584 /// having no badge would read as the section failing to say rather than as the
585 /// ordinary case.
586 enum Standing {
587 /// On a cycle, so it can never become available.
588 Cycle,
589 /// Waiting on something, some number of steps away.
590 Blocked,
591 /// Nothing in the way.
592 Ready,
593 }
594
595 impl Standing {
596 /// Which of the three this task is in.
597 fn of(task: &Task) -> Self {
598 if task.graph.in_cycle {
599 Self::Cycle
600 } else if task.is_blocked() {
601 Self::Blocked
602 } else {
603 Self::Ready
604 }
605 }
606 }
607
608 /// What the blocked badge reads, which counts the steps.
609 fn blocked_word(task: &Task) -> String {
610 if task.graph.block_depth == 1 {
611 "Blocked, 1 step away".to_owned()
612 } else {
613 format!("Blocked, {} steps away", task.graph.block_depth)
614 }
615 }
616
617 /// What this task unblocks, counted.
618 fn unblocks_word(task: &Task) -> String {
619 let n = task.graph.unblocks_count;
620 format!("unblocks {n} task{}", if n == 1 { "" } else { "s" })
621 }
622
623 /// Whether both lists are empty, which is the one thing the section says
624 /// instead of showing.
625 fn no_edges(dependencies: &Dependencies) -> bool {
626 dependencies.blockers.is_empty() && dependencies.dependents.is_empty()
627 }
628
629 /// The edges around a task, and what may be added.
630 struct Dependencies {
631 /// What blocks it.
632 blockers: Vec<LinkedTaskRef>,
633 /// What waits on it.
634 dependents: Vec<LinkedTaskRef>,
635 /// The tasks that could block it, already filtered.
636 candidates: Vec<Choice>,
637 }
638
639 declare! {
640 /// The dependencies section: what blocks this task and what waits on it.
641 ///
642 /// Two lists of rows, a marker, a count, and a select of candidate tasks.
643 ///
644 /// # The picker is a field, not a modal
645 ///
646 /// A modal is an arrangement and the router answers one screen at a time
647 /// (see [`screen`]), so what is described is the *choice*: a select of the
648 /// tasks that could block this one, submitted as an ordinary form. A
649 /// webview may still draw it as a modal.
650 ///
651 /// The candidate filter is: not this task, not already a blocker, not
652 /// deleted. The repository is the authority and refuses a cycle-closing
653 /// edge naming the chain, which [`add_blocker`] passes through verbatim.
654 shape dependencies_section(task: &Task, dependencies: &Dependencies) -> Vec<Node>;
655
656 section "Dependencies";
657
658 given Standing::of(task) {
659 Standing::Cycle -> badge "In a cycle" { tone Danger; }
660 Standing::Blocked -> badge blocked_word(task) { tone Warning; }
661 otherwise -> badge "Ready" { tone Success; }
662 }
663
664 text unblocks_word(task) when task.graph.unblocks_count over 0;
665
666 toned "This task sits on a dependency cycle, so it can never become available. Remove \
667 one of the edges below to break it."
668 makeover_layout::Tone::Danger
669 when task.graph.in_cycle;
670
671 subsection "Blocked by" unless dependencies.blockers.is_empty();
672
673 list {
674 for entry in dependencies.blockers.iter() {
675 include dependency_row(task.id, entry, Role::Blocker);
676 }
677 } unless dependencies.blockers.is_empty();
678
679 subsection "Blocks" unless dependencies.dependents.is_empty();
680
681 list {
682 for entry in dependencies.dependents.iter() {
683 include dependency_row(task.id, entry, Role::Dependent);
684 }
685 } unless dependencies.dependents.is_empty();
686
687 text "Nothing blocks this task and nothing waits on it." when no_edges(dependencies);
688
689 // A completed task is not offered a new blocker, the JS's own condition,
690 // and neither is one with nothing left to depend on: an empty select is a
691 // control that cannot be used, which is the toast the JS shows instead.
692 form post "/tasks/{task.id}/blockers"
693 when task.status is_not TaskStatus::Completed
694 and not dependencies.candidates.is_empty() {
695 submit "Add blocker";
696
697 field Select "blocker" "Must be completed first" {
698 options dependencies.candidates.clone();
699 hint "This task stays unavailable until that one is done.";
700 }
701 }
702 }
703
704 /// The tasks that could block this one.
705 ///
706 /// `pickBlocker`'s filter, moved to where the data is. The JS fetches every
707 /// task and filters in the browser; here the same rule runs before anything is
708 /// described, so a screen never offers a choice the repository would refuse.
709 fn blocker_candidates(
710 state: &AppState,
711 task: &Task,
712 blockers: &[LinkedTaskRef],
713 ) -> Result<Vec<Choice>, RouteError> {
714 let already: std::collections::HashSet<TaskId> = blockers.iter().map(|b| b.id).collect();
715 Ok(state
716 .tasks
717 .list_all(DESKTOP_USER_ID)
718 .map_err(|error| RouteError::internal(error.to_string()))?
719 .into_iter()
720 .filter(|other| other.id != task.id && !already.contains(&other.id))
721 .map(|other| {
722 let label = match &other.project_name {
723 Some(project) => format!("{} ({project})", other.title),
724 None => other.title.clone(),
725 };
726 Choice::new(other.id.to_string(), label)
727 })
728 .collect())
729 }
730
731 /// Everything the task drawer draws, read once.
732 struct Drawer {
733 /// The task itself.
734 task: Task,
735 /// Its tracked sessions, newest as the store lists them.
736 sessions: Vec<TimeSession>,
737 /// Its streak stats, for a task with a recurrence chain. `None` for a
738 /// one-off, which is what makes the whole completion-history section absent
739 /// rather than empty.
740 streak: Option<StreakInfo>,
741 /// The edges around it, and what may be added.
742 dependencies: Dependencies,
743 }
744
745 /// The drawer's read.
746 fn drawer(state: &AppState, id: TaskId) -> Result<Drawer, RouteError> {
747 let task = load(state, id)?;
748 let blockers = state
749 .tasks
750 .list_blockers(DESKTOP_USER_ID, id)
751 .map_err(|error| RouteError::internal(error.to_string()))?;
752 Ok(Drawer {
753 sessions: state
754 .tasks
755 .list_time_sessions(id, DESKTOP_USER_ID)
756 .map_err(|error| RouteError::internal(error.to_string()))?,
757 streak: streak_for(state, &task)?,
758 dependencies: Dependencies {
759 candidates: blocker_candidates(state, &task, &blockers)?,
760 dependents: state
761 .tasks
762 .list_dependents(DESKTOP_USER_ID, id)
763 .map_err(|error| RouteError::internal(error.to_string()))?,
764 blockers,
765 },
766 task,
767 })
768 }
769
770 /// Whether the subtasks section is drawn at all.
771 ///
772 /// The JS hides it on a completed task with none, on the grounds that there is
773 /// nothing to add one for any more.
774 fn offers_subtasks(task: &Task) -> bool {
775 !task.subtasks.is_empty() || task.status != TaskStatus::Completed
776 }
777
778 declare! {
779 /// The whole screen.
780 ///
781 /// Built here rather than inside the route because every write answers with
782 /// it, for the reason the projects screen gives: a write lands in more than
783 /// one section and a `Response` names one region.
784 ///
785 /// # Edit is an address, not an overlay
786 ///
787 /// A screen that offers a control which opens a form over itself has to
788 /// describe two arrangements at once, which the router cannot answer. Edit
789 /// addresses [`edit_screen`] instead.
790 ///
791 /// The drawer is reached from a row on the task list, so the place it marks
792 /// is the list it came from. `navigation.js` says the same thing with
793 /// `TAB_GROUPS["task-overview"] = "work"`.
794 shape screen(drawer: &Drawer) -> Screen;
795
796 screen list_detail "Task" false {
797 at_place super::shell::TASKS;
798
799 region "task-band" as Band {
800 page &drawer.task.title;
801
802 act "Edit" to get "/tasks/{drawer.task.id}/edit";
803 act "Complete" to post "/tasks/{drawer.task.id}/complete"
804 when drawer.task.status is_not TaskStatus::Completed;
805
806 // The confirmation is the description's now, as of `524a63fe`. It
807 // was the JS's -- `confirmDelete` at 17 call sites -- so the
808 // described screen deleted without asking where the shipped one
809 // asks, which is the described screen being worse than what it
810 // replaces.
811 act "Delete" to post "/tasks/{drawer.task.id}/delete" {
812 tone Danger;
813 confirm "Are you sure you want to delete this task? This cannot be undone.";
814 }
815 }
816
817 region "task-overview" as Pane {
818 for streak in drawer.streak.iter() {
819 extend habit_section(streak, drawer.task.id);
820 }
821
822 extend metadata(&drawer.task);
823 extend subtasks_section(&drawer.task) when offers_subtasks(&drawer.task);
824 extend dependencies_section(&drawer.task, &drawer.dependencies);
825 extend time_section(&drawer.task, &drawer.sessions);
826 extend notes_section(&drawer.task);
827 }
828 }
829 }
830
831 /// The whole overview.
832 fn overview(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
833 Ok(screen(&drawer(state, task_id(&request)?)?).into())
834 }
835
836 /// The statuses the edit form offers, which are `task-forms.js:STATUS_OPTIONS`.
837 ///
838 /// [`TaskStatus::Deleted`] is not among them, for the reason
839 /// [`super::move_to`] gives: deleting is its own control, and a status select
840 /// that could delete would put it one option away from Completed.
841 const EDIT_STATUSES: [&str; 3] = ["Pending", "Started", "Completed"];
842
843 /// `task-forms.js:PRIORITIES`, in its order, which is lowest first.
844 const EDIT_PRIORITIES: [&str; 3] = ["Low", "Medium", "High"];
845
846 /// `task-forms.js:RECURRENCE_OPTIONS`.
847 const EDIT_RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
848
849 /// The due date as the form shows it, which is local wall clock.
850 ///
851 /// `getTaskFormFields` builds the same `YYYY-MM-DDTHH:MM` out of a `Date`, and
852 /// [`goingson_core::parse_natural_date`] reads that format back, so the value
853 /// the form offers is a value the form accepts. A prefill the parser would
854 /// reject is a field that cannot be left alone.
855 fn due_value(task: &Task) -> String {
856 task.due
857 .map(|due| {
858 due.with_timezone(&Local)
859 .format("%Y-%m-%dT%H:%M")
860 .to_string()
861 })
862 .unwrap_or_default()
863 }
864
865 /// Everything the edit form needs, read once.
866 struct Editing {
867 /// The task being edited.
868 task: Task,
869 /// The projects it may be filed under, with "none" at the head.
870 projects: Vec<Choice>,
871 /// The contacts it may name.
872 contacts: Vec<Choice>,
873 /// The milestones of its project, empty when it has none.
874 milestones: Vec<Choice>,
875 /// What the last submission got wrong, by field name.
876 errors: Vec<(String, String)>,
877 /// What that submission sent, so a refused form comes back filled.
878 submitted: quasi_router::Params,
879 }
880
881 /// The edit form's read.
882 fn editing(
883 state: &AppState,
884 task: Task,
885 errors: &[(&str, String)],
886 submitted: Option<&quasi_router::Params>,
887 ) -> Result<Editing, RouteError> {
888 let offered = |none: &str, values: Vec<(String, String)>| -> Vec<Choice> {
889 let mut options = vec![Choice::new("", none)];
890 options.extend(values.into_iter().map(|(id, label)| Choice::new(id, label)));
891 options
892 };
893
894 let projects = state
895 .projects
896 .list_all(DESKTOP_USER_ID)
897 .map_err(|error| RouteError::internal(error.to_string()))?;
898 let contacts = state
899 .contacts
900 .list_all(DESKTOP_USER_ID)
901 .map_err(|error| RouteError::internal(error.to_string()))?;
902 let milestones = match task.project_id {
903 Some(project_id) => state
904 .milestones
905 .list_by_project(project_id, DESKTOP_USER_ID)
906 .map_err(|error| RouteError::internal(error.to_string()))?,
907 None => Vec::new(),
908 };
909
910 Ok(Editing {
911 projects: offered(
912 "No Project",
913 projects
914 .into_iter()
915 .map(|project| (project.id.to_string(), project.name))
916 .collect(),
917 ),
918 contacts: offered(
919 "No Contact",
920 contacts
921 .into_iter()
922 .map(|contact| (contact.id.to_string(), contact.display_name))
923 .collect(),
924 ),
925 milestones: offered(
926 "No Milestone",
927 milestones
928 .into_iter()
929 .map(|milestone| (milestone.id.to_string(), milestone.name))
930 .collect(),
931 ),
932 errors: errors
933 .iter()
934 .map(|(field, message)| ((*field).to_owned(), message.clone()))
935 .collect(),
936 // An unanswered form refills from an empty `Params`, which is a no-op,
937 // so the shape needs no guard around it.
938 submitted: submitted.cloned().unwrap_or_default(),
939 task,
940 })
941 }
942
943 impl Editing {
944 /// Whether a named field was refused.
945 fn refused(&self, name: &str) -> bool {
946 self.errors.iter().any(|(field, _)| field == name)
947 }
948
949 /// What it was refused for. Total, because a hole is evaluated whether or
950 /// not the setting it feeds is placed.
951 fn refusal(&self, name: &str) -> String {
952 self.errors
953 .iter()
954 .find(|(field, _)| field == name)
955 .map_or_else(String::new, |(_, message)| message.clone())
956 }
957
958 /// An optional id as the select spells it, which is the empty string for
959 /// none.
960 fn id_or_none(id: Option<impl ToString>) -> String {
961 id.map(|id| id.to_string()).unwrap_or_default()
962 }
963
964 /// The estimate as the box shows it, blank where there is none.
965 fn estimate_box(&self) -> String {
966 Self::id_or_none(self.task.estimated_minutes)
967 }
968 }
969
970 declare! {
971 /// The edit form, at its own address.
972 ///
973 /// The form is a screen of its own, reached by address, and the control on
974 /// the overview is a link to it rather than a second arrangement drawn on
975 /// top. Cancel is the overview's own address, and the overview is rebuilt
976 /// from the database rather than restored from memory.
977 shape edit_screen(editing: &Editing) -> Screen;
978
979 screen list_detail "Edit task" false {
980 at_place super::shell::TASKS;
981
982 region "task-band" as Band {
983 page "Edit {editing.task.title}";
984 act "Cancel" to get "/tasks/{editing.task.id}";
985 }
986
987 region "task-overview" as Pane {
988 form post "/tasks/{editing.task.id}" {
989 submit "Save task";
990
991 field Text "title" "Title" {
992 required;
993 value &editing.task.title;
994 placeholder "What needs to be done?";
995 error editing.refusal("title") when editing.refused("title");
996 refilled &editing.submitted;
997 }
998
999 field Textarea "description" "Details" {
1000 value &editing.task.description;
1001 placeholder "Anything the title does not cover (optional)";
1002 error editing.refusal("description") when editing.refused("description");
1003 refilled &editing.submitted;
1004 }
1005
1006 field Select "project_id" "Project" {
1007 options editing.projects.clone();
1008 value Editing::id_or_none(editing.task.project_id);
1009 error editing.refusal("project_id") when editing.refused("project_id");
1010 refilled &editing.submitted;
1011 }
1012
1013 field Select "status" "Status" {
1014 for status in EDIT_STATUSES {
1015 option Choice::new(status, status);
1016 }
1017 value editing.task.status.as_str();
1018 error editing.refusal("status") when editing.refused("status");
1019 refilled &editing.submitted;
1020 }
1021
1022 field Select "priority" "Priority" {
1023 for priority in EDIT_PRIORITIES {
1024 option Choice::new(priority, priority);
1025 }
1026 value editing.task.priority.db_value();
1027 error editing.refusal("priority") when editing.refused("priority");
1028 refilled &editing.submitted;
1029 }
1030
1031 field Text "due" "Due Date (optional)" {
1032 value due_value(&editing.task);
1033 placeholder "tomorrow, friday 3pm, 2026-12-25...";
1034 error editing.refusal("due") when editing.refused("due");
1035 refilled &editing.submitted;
1036 }
1037
1038 field Text "tags" "Tags (comma-separated)" {
1039 value editing.task.tags.join(", ");
1040 placeholder "work, urgent, meeting";
1041 error editing.refusal("tags") when editing.refused("tags");
1042 refilled &editing.submitted;
1043 }
1044
1045 field Select "recurrence" "Recurrence" {
1046 for pattern in EDIT_RECURRENCES {
1047 option Choice::new(pattern, pattern);
1048 }
1049 value editing.task.recurrence.db_value();
1050 hint "Completing a recurring task auto-creates the next occurrence";
1051 error editing.refusal("recurrence") when editing.refused("recurrence");
1052 refilled &editing.submitted;
1053 }
1054
1055 field Number "estimated_minutes" "Estimated Time (minutes)" {
1056 value editing.estimate_box();
1057 placeholder "e.g. 30, 60, 120";
1058 hint "Used for day plan scheduling and time tracking progress";
1059 error editing.refusal("estimated_minutes")
1060 when editing.refused("estimated_minutes");
1061 refilled &editing.submitted;
1062 }
1063
1064 field Select "contact_id" "Contact" {
1065 options editing.contacts.clone();
1066 value Editing::id_or_none(editing.task.contact_id);
1067 error editing.refusal("contact_id") when editing.refused("contact_id");
1068 refilled &editing.submitted;
1069 }
1070
1071 field Select "milestone_id" "Milestone" {
1072 options editing.milestones.clone();
1073 value Editing::id_or_none(editing.task.milestone_id);
1074 hint "Group tasks into project phases; milestones are managed per project";
1075 error editing.refusal("milestone_id") when editing.refused("milestone_id");
1076 refilled &editing.submitted;
1077 }
1078 }
1079 }
1080 }
1081 }
1082
1083 /// The edit form.
1084 fn edit(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1085 let task = load(state, task_id(&request)?)?;
1086 Ok(edit_screen(&editing(state, task, &[], None)?).into())
1087 }
1088
1089 /// What the JS form refuses, refused here.
1090 ///
1091 /// The title length is `getTaskFormFields`'s own `validate` closure. The empty
1092 /// title is `update_task`'s, which checks it server-side and is the only one of
1093 /// the two that a described form could not skip.
1094 fn validate_edit(title: &str) -> Vec<(&'static str, String)> {
1095 let mut errors = Vec::new();
1096 if title.is_empty() {
1097 errors.push(("title", "A task needs a title.".to_owned()));
1098 } else if title.chars().count() > 80 {
1099 errors.push(("title", "Maximum 80 characters".to_owned()));
1100 }
1101 errors
1102 }
1103
1104 /// Save the edited task, or answer with the form saying why not.
1105 ///
1106 /// The write is [`UpdateTask`] through the repository rather than the
1107 /// `update_task` command, which is a Tauri wrapper around exactly this. What
1108 /// the command holds that is worth keeping — the urgency recalculation, the
1109 /// title/description split, and reading `scheduled_start` and
1110 /// `scheduled_duration` off the stored row so a time-blocked task does not lose
1111 /// its block on an unrelated edit — is in core and is called here for the same
1112 /// reason.
1113 fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1114 let id = task_id(&request)?;
1115 let task = load(state, id)?;
1116
1117 let field = |name: &str| {
1118 request
1119 .payload
1120 .get(name)
1121 .unwrap_or_default()
1122 .trim()
1123 .to_owned()
1124 };
1125 let title = field("title");
1126 let description = field("description");
1127 let mut errors = validate_edit(&title);
1128
1129 let status = super::parse_choice::<TaskStatus>(&request.payload, "status", &mut errors)
1130 .filter(|status| EDIT_STATUSES.contains(&status.as_str()));
1131 if status.is_none() && !errors.iter().any(|(name, _)| *name == "status") {
1132 errors.push(("status", "Not a status a control can set.".to_owned()));
1133 }
1134 let priority = super::parse_choice::<Priority>(&request.payload, "priority", &mut errors);
1135 let recurrence = super::parse_choice::<Recurrence>(&request.payload, "recurrence", &mut errors);
1136
1137 // Blank is "no due date", which is how the field is cleared. Anything else
1138 // has to parse, and an unparseable date is refused rather than dropped:
1139 // dropping it is a task that silently loses its deadline on an edit that
1140 // was about something else. `parse_natural_date` is the same function the
1141 // JS reaches through the `parse_natural_date` command, so the two agree on
1142 // what "friday 3pm" means.
1143 let raw_due = field("due");
1144 let due = if raw_due.is_empty() {
1145 None
1146 } else {
1147 match goingson_core::parse_natural_date(&raw_due, Local::now().naive_local())
1148 .and_then(|when| when.and_local_timezone(Local).single())
1149 {
1150 Some(when) => Some(when.with_timezone(&Utc)),
1151 None => {
1152 errors.push((
1153 "due",
1154 "Date not recognized. Try \"tomorrow\", \"friday 3pm\", or \"2026-12-25\"."
1155 .to_owned(),
1156 ));
1157 None
1158 }
1159 }
1160 };
1161
1162 let estimated_minutes = match field("estimated_minutes").as_str() {
1163 "" => None,
1164 raw => match raw.parse::<i32>() {
1165 Ok(minutes) if minutes >= 0 => Some(minutes),
1166 _ => {
1167 errors.push(("estimated_minutes", "A number of minutes.".to_owned()));
1168 None
1169 }
1170 },
1171 };
1172
1173 let project_id = parse_optional_id(&request.payload, "project_id", &mut errors);
1174 let contact_id = parse_optional_id(&request.payload, "contact_id", &mut errors);
1175 let milestone_id = parse_optional_id(&request.payload, "milestone_id", &mut errors);
1176
1177 // A milestone belongs to a project, and the form offered the milestones of
1178 // the project the task was in. Moving both at once is refused rather than
1179 // stored, because the pairing the form could offer and the pairing the
1180 // submission carries are not the same thing. See [`edit_fields`].
1181 if milestone_id.flatten().is_some() && project_id.flatten() != task.project_id {
1182 errors.push((
1183 "milestone_id",
1184 "Move the task first, then file it under a milestone of its new project.".to_owned(),
1185 ));
1186 }
1187
1188 let (Some(status), Some(priority), Some(recurrence)) = (status, priority, recurrence) else {
1189 return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into());
1190 };
1191 let (Some(project_id), Some(contact_id), Some(milestone_id)) =
1192 (project_id, contact_id, milestone_id)
1193 else {
1194 return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into());
1195 };
1196 if !errors.is_empty() {
1197 return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into());
1198 }
1199
1200 let tags: Vec<String> = field("tags")
1201 .split(',')
1202 .map(|tag| tag.trim().to_owned())
1203 .filter(|tag| !tag.is_empty())
1204 .collect();
1205
1206 let context = state
1207 .tasks
1208 .get_update_context(id, DESKTOP_USER_ID)
1209 .map_err(|error| RouteError::internal(error.to_string()))?
1210 .ok_or_else(|| RouteError::not_found("no such task"))?;
1211
1212 state
1213 .tasks
1214 .update(
1215 id,
1216 DESKTOP_USER_ID,
1217 UpdateTask {
1218 project_id,
1219 milestone_id,
1220 contact_id,
1221 urgency: goingson_core::calculate_urgency(
1222 &priority,
1223 &status,
1224 due.as_ref(),
1225 &context.created_at,
1226 &tags,
1227 ),
1228 title,
1229 description,
1230 status,
1231 priority,
1232 due,
1233 tags,
1234 recurrence,
1235 // Threaded rather than rebuilt: the form cannot ask for it.
1236 // See [`edit_fields`].
1237 recurrence_rule: task.recurrence_rule.clone(),
1238 scheduled_start: context.scheduled_start,
1239 scheduled_duration: context.scheduled_duration,
1240 estimated_minutes,
1241 },
1242 )
1243 .map_err(|error| RouteError::internal(error.to_string()))?
1244 .ok_or_else(|| RouteError::not_found("no such task"))?;
1245
1246 Ok(wrote(state, id)?.toast(makeover_layout::Tone::Success, "Task saved"))
1247 }
1248
1249 /// Answer a write with the screen it happened on, re-read.
1250 ///
1251 /// Re-read rather than patched in memory: the write is the database's to
1252 /// confirm, and a screen rebuilt from what the handler hoped happened is how a
1253 /// screen disagrees with its own storage.
1254 fn wrote(state: &AppState, id: TaskId) -> Result<Response, RouteError> {
1255 Ok(screen(&drawer(state, id)?).into())
1256 }
1257
1258 /// Mark the task complete.
1259 ///
1260 /// `complete` handles the recurring case itself, minting the next instance, so
1261 /// this does not branch on recurrence. Answering with the same address then
1262 /// shows the completed instance rather than the new one, which matches what the
1263 /// JS does: it re-opens the task it was showing.
1264 fn complete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1265 let id = task_id(&request)?;
1266 // Through [`super::move_to`], so this drawer, the board and the task list
1267 // are one answer to what completing a task means. See the note there for
1268 // what the repository's `complete` leaves out.
1269 let task = state
1270 .tasks
1271 .get_by_id(id, DESKTOP_USER_ID)
1272 .map_err(|error| RouteError::internal(error.to_string()))?
1273 .ok_or_else(|| RouteError::not_found("no such task"))?;
1274 super::move_to(state, &task, &TaskStatus::Completed)?;
1275 wrote(state, id)
1276 }
1277
1278 /// Delete the task.
1279 ///
1280 /// # Deleting answers with a different address
1281 ///
1282 /// Deleting the thing a screen is about is the one case where the right answer
1283 /// is a *different* address. [`Response`] carries an
1284 /// [`Outcome`](quasi_router::Outcome) that can be a redirect, plus a notice
1285 /// beside it: a redirect alone cannot say what happened, because the list it
1286 /// lands on looks the same whether a task was deleted or the user navigated.
1287 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1288 let id = task_id(&request)?;
1289 let deleted = state
1290 .tasks
1291 .delete(id, DESKTOP_USER_ID)
1292 .map_err(|error| RouteError::internal(error.to_string()))?;
1293 if !deleted {
1294 return Err(RouteError::not_found("no such task"));
1295 }
1296 Ok(Response::goto(Action::get("/tasks")).toast(makeover_layout::Tone::Success, "Task deleted"))
1297 }
1298
1299 /// Add a subtask.
1300 fn add_subtask(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1301 let id = task_id(&request)?;
1302 let text = request.payload.get("text").unwrap_or_default().trim();
1303 // An empty add is the JS's early return, not an error: the user pressed the
1304 // button with nothing typed and the screen should simply not change.
1305 if !text.is_empty() {
1306 state
1307 .tasks
1308 .add_subtask(id, DESKTOP_USER_ID, text)
1309 .map_err(|error| RouteError::internal(error.to_string()))?
1310 .ok_or_else(|| RouteError::not_found("no such task"))?;
1311 }
1312 wrote(state, id)
1313 }
1314
1315 /// Tick or untick a subtask.
1316 fn toggle_subtask(
1317 state: &AppState,
1318 request: quasi_router::Request,
1319 ) -> Result<Response, RouteError> {
1320 let id = task_id(&request)?;
1321 let raw = request
1322 .captures
1323 .get("sub")
1324 .ok_or_else(|| RouteError::not_found("no subtask id"))?;
1325 let sub = goingson_core::SubtaskId::from(
1326 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a subtask id"))?,
1327 );
1328 state
1329 .tasks
1330 .toggle_subtask(sub, DESKTOP_USER_ID)
1331 .map_err(|error| RouteError::internal(error.to_string()))?
1332 .ok_or_else(|| RouteError::not_found("no such subtask"))?;
1333 wrote(state, id)
1334 }
1335
1336 /// Add a note.
1337 fn add_note(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1338 let id = task_id(&request)?;
1339 let note = request.payload.get("note").unwrap_or_default().trim();
1340 if !note.is_empty() {
1341 state
1342 .tasks
1343 .add_annotation(id, DESKTOP_USER_ID, note)
1344 .map_err(|error| RouteError::internal(error.to_string()))?;
1345 }
1346 wrote(state, id)
1347 }
1348
1349 /// Draw an edge: the picked task must finish before this one starts.
1350 ///
1351 /// A cycle-closing edge is refused by the repository with a message naming the
1352 /// chain already in the way, and that message is what the user sees. It is the
1353 /// only useful thing to say here, so it is passed through rather than replaced
1354 /// with a generic failure — the same call `addBlocker` makes, and a toast for
1355 /// the same reason: the screen is fine, one submission was not.
1356 fn add_blocker(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1357 let id = task_id(&request)?;
1358 let raw = request.payload.get("blocker").unwrap_or_default();
1359 // An empty submit is the JS's early return: the user pressed the button
1360 // without picking, and the screen should simply not change.
1361 if raw.trim().is_empty() {
1362 return wrote(state, id);
1363 }
1364 let blocker = TaskId::from(
1365 uuid::Uuid::parse_str(raw.trim()).map_err(|_| RouteError::not_found("not a task id"))?,
1366 );
1367 state
1368 .tasks
1369 .add_dependency(DESKTOP_USER_ID, id, blocker)
1370 .map_err(|error| RouteError::conflict(error.to_string()).as_toast())?;
1371 wrote(state, id)
1372 }
1373
1374 /// Cut an edge, from either side of it.
1375 fn remove_dependency(
1376 state: &AppState,
1377 request: quasi_router::Request,
1378 ) -> Result<Response, RouteError> {
1379 let id = task_id(&request)?;
1380 let raw = request
1381 .captures
1382 .get("other")
1383 .ok_or_else(|| RouteError::not_found("no task id"))?;
1384 let other = TaskId::from(
1385 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
1386 );
1387 let (blocked, blocker) =
1388 match Role::from_payload(request.payload.get("role").unwrap_or_default()) {
1389 Ok(Role::Blocker) => (id, other),
1390 Ok(Role::Dependent) => (other, id),
1391 Err(error) => return Err(error),
1392 };
1393 let removed = state
1394 .tasks
1395 .remove_dependency(DESKTOP_USER_ID, blocked, blocker)
1396 .map_err(|error| RouteError::internal(error.to_string()))?;
1397 if !removed {
1398 return Err(RouteError::not_found("no such dependency"));
1399 }
1400 // The viewed task's screen, not the edge's other end: the address names
1401 // where the user is standing, which is what makes one route serve both
1402 // lists.
1403 wrote(state, id)
1404 }
1405
1406 /// The task overview's routes.
1407 #[must_use]
1408 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1409 router
1410 .get("/tasks/{id}/edit", edit)
1411 .get("/tasks/{id}", overview)
1412 .post("/tasks/{id}", update)
1413 .post("/tasks/{id}/blockers", add_blocker)
1414 .post("/tasks/{id}/dependencies/{other}/remove", remove_dependency)
1415 .post("/tasks/{id}/complete", complete)
1416 .post("/tasks/{id}/delete", remove)
1417 .post("/tasks/{id}/subtasks", add_subtask)
1418 .post("/tasks/{id}/subtasks/{sub}/toggle", toggle_subtask)
1419 .post("/tasks/{id}/notes", add_note)
1420 }
1421