max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+698 insertions,
-10 deletions
| @@ -123,6 +123,14 @@ | |||
| 123 | 123 | row = row.meta(project); | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | // Whether the card is available to work on. `tasks-kanban.js` draws the | |
| 127 | + | // same marker by calling the task row's own renderer; here it is | |
| 128 | + | // `Availability`, and for the same reason: a second copy is how the board | |
| 129 | + | // card and the task row drifted apart to begin with. | |
| 130 | + | if let Some(marker) = super::Availability::of(task).marker() { | |
| 131 | + | row = row.token(marker); | |
| 132 | + | } | |
| 133 | + | ||
| 126 | 134 | // The due date, and whether it has passed. Overdue is a judgment the app | |
| 127 | 135 | // makes and the renderer cannot, so it travels as a tone rather than as a | |
| 128 | 136 | // class the way `kanban-card-due.overdue` does. |
| @@ -208,6 +208,12 @@ | |||
| 208 | 208 | row = row.token(Tag::badge("out of order").tone(Tone::Warning)); | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| 211 | + | // Which of the offered tasks is worth scheduling first, which the | |
| 212 | + | // gate cannot say. Only the frees-work half: see | |
| 213 | + | // [`crate::quasi::Availability::frees_marker`]. | |
| 214 | + | if let Some(marker) = super::Availability::reported(task).frees_marker() { | |
| 215 | + | row = row.token(marker); | |
| 216 | + | } | |
| 211 | 217 | row.activate(Action::get(format!("/tasks/{}", task.id))) | |
| 212 | 218 | }) | |
| 213 | 219 | .collect(); |
| @@ -96,10 +96,46 @@ | |||
| 96 | 96 | //! | |
| 97 | 97 | //! Together with the 50 above, these 77 sites are what the done-condition | |
| 98 | 98 | //! actually has to answer for. Porting every candidate screen leaves them. | |
| 99 | + | //! | |
| 100 | + | //! # The ports checked against their counterparts | |
| 101 | + | //! | |
| 102 | + | //! Swept 2026-08-15, after [`tasks`] turned out to be describing a screen that | |
| 103 | + | //! had grown a whole section since the port. The check is mechanical: for each | |
| 104 | + | //! module, every commit touching its JS counterpart between the commit that | |
| 105 | + | //! added the module and now, and whether that commit touched the description | |
| 106 | + | //! too. | |
| 107 | + | //! | |
| 108 | + | //! Three of the ten had drifted, and all three by the same commit. `0df3488`, | |
| 109 | + | //! "Every task surface says whether the task is available", added the | |
| 110 | + | //! dependency markers to the board card, the project dashboard card and the day | |
| 111 | + | //! plan pool, and touched no described module. The board and the day view were | |
| 112 | + | //! ported *after* it and still did not carry them, so this is not only a | |
| 113 | + | //! JS-grew-later failure: it is that nobody was reading the counterpart. | |
| 114 | + | //! | |
| 115 | + | //! | Module | Drift | Closed | | |
| 116 | + | //! |---|---|---| | |
| 117 | + | //! | [`tasks`] | `338aa9f`, the whole Dependencies section | `dependencies_section` | | |
| 118 | + | //! | [`board`] | `0df3488`, both markers on the card | [`Availability::marker`] | | |
| 119 | + | //! | [`projects`] | `0df3488`, both markers on the dashboard card | [`Availability::marker`] | | |
| 120 | + | //! | [`day_planning`] | `0df3488`, the unblocks marker in the pool | [`Availability::frees_marker`] | | |
| 121 | + | //! | |
| 122 | + | //! Clean: [`contacts`], [`settings`], [`weekly_review`], [`emails`], | |
| 123 | + | //! [`monthly_review`], [`problems`]. The last three each have a commit touching | |
| 124 | + | //! their JS since the port, and each of those touched the description in the | |
| 125 | + | //! same commit, which is the arrangement that works. | |
| 126 | + | //! | |
| 127 | + | //! **Nothing automates this.** The sweep is a person running `git log` against | |
| 128 | + | //! two paths, so it is only as current as the last time someone thought to. | |
| 129 | + | //! What would close it is a check that fails when a JS file changes without its | |
| 130 | + | //! described module changing, which is `witchbroom`-shaped work rather than | |
| 131 | + | //! anything this module can do to itself. | |
| 99 | 132 | ||
| 100 | 133 | use std::sync::Arc; | |
| 101 | 134 | ||
| 135 | + | use goingson_core::Task; | |
| 136 | + | use makeover_layout::Tone; | |
| 102 | 137 | use quasi_router::Router; | |
| 138 | + | use quasi_router::screen::Tag; | |
| 103 | 139 | ||
| 104 | 140 | use crate::state::AppState; | |
| 105 | 141 | ||
| @@ -114,6 +150,90 @@ | |||
| 114 | 150 | pub mod tasks; | |
| 115 | 151 | pub mod weekly_review; | |
| 116 | 152 | ||
| 153 | + | /// Where a task sits in the dependency graph, for the surfaces that draw it. | |
| 154 | + | /// | |
| 155 | + | /// Every task surface says whether the task is available. The shipped screens | |
| 156 | + | /// arrived at that on 2026-08-09 (`0df3488`) after the board card, the project | |
| 157 | + | /// dashboard card and the day plan pool each drew a task without the markers | |
| 158 | + | /// the task row had grown, so the same task read as blocked in one view and as | |
| 159 | + | /// ordinary work in the next. They were fixed by calling the row's own | |
| 160 | + | /// renderers rather than growing a second copy, and that is why this lives | |
| 161 | + | /// here rather than three times over. | |
| 162 | + | /// | |
| 163 | + | /// The described side had the same drift and it arrived the same way: three | |
| 164 | + | /// ports (the dashboard, the day view, the board) each said the fact its own | |
| 165 | + | /// JS counterpart said at port time, and none said this one. | |
| 166 | + | /// | |
| 167 | + | /// # What the description cannot carry | |
| 168 | + | /// | |
| 169 | + | /// The shipped badges put the detail in a `title`: the block depth behind | |
| 170 | + | /// "Blocked", the freed count's wording behind "Unblocks N", the repair | |
| 171 | + | /// instruction behind "Cycle". A [`Tag`] has a label and a tone and no hint, so | |
| 172 | + | /// the labels here are the shipped visible text and the detail has nowhere to | |
| 173 | + | /// go. Whether tokens should carry a hint is a vocabulary question and is not | |
| 174 | + | /// answered by this type quietly dropping it. | |
| 175 | + | #[derive(Clone, Copy)] | |
| 176 | + | pub(crate) struct Availability { | |
| 177 | + | /// Something unfinished is in this task's way. | |
| 178 | + | blocked: bool, | |
| 179 | + | /// It sits on a cycle, so it can never open. | |
| 180 | + | in_cycle: bool, | |
| 181 | + | /// How many tasks finishing this one would free. | |
| 182 | + | unblocks: u32, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | impl Availability { | |
| 186 | + | /// Read it off a task. | |
| 187 | + | pub(crate) fn of(task: &Task) -> Self { | |
| 188 | + | Self { | |
| 189 | + | blocked: task.is_blocked(), | |
| 190 | + | in_cycle: task.graph.in_cycle, | |
| 191 | + | unblocks: task.graph.unblocks_count, | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Read it off the response shape, for the screens served one. | |
| 196 | + | /// | |
| 197 | + | /// `TaskResponse` flattens [`goingson_core::GraphPosition`] into three | |
| 198 | + | /// fields rather than holding it, so this is the same three facts arriving | |
| 199 | + | /// by the other route. | |
| 200 | + | pub(crate) fn reported(task: &crate::commands::TaskResponse) -> Self { | |
| 201 | + | Self { | |
| 202 | + | blocked: task.is_blocked, | |
| 203 | + | in_cycle: task.in_cycle, | |
| 204 | + | unblocks: task.unblocks_count, | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// The marker a task surface carries, if any. | |
| 209 | + | /// | |
| 210 | + | /// One token at most. The two are mutually exclusive by construction: a | |
| 211 | + | /// blocked task's freed count is real but not actionable, so it is omitted | |
| 212 | + | /// rather than competing with the blocked badge, and a ready task with | |
| 213 | + | /// nothing downstream is the ordinary case and carries nothing at all. | |
| 214 | + | pub(crate) fn marker(self) -> Option<Tag> { | |
| 215 | + | if self.in_cycle { | |
| 216 | + | return Some(Tag::badge("Cycle").tone(Tone::Danger)); | |
| 217 | + | } | |
| 218 | + | if self.blocked { | |
| 219 | + | return Some(Tag::badge("Blocked").tone(Tone::Warning)); | |
| 220 | + | } | |
| 221 | + | self.frees_marker() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// The "frees other work" half alone. | |
| 225 | + | /// | |
| 226 | + | /// The day plan's pool takes only this one. Its gate already refuses to | |
| 227 | + | /// offer a blocked task until every blocker it has is in the day, so a | |
| 228 | + | /// bare "Blocked" there would contradict the plan's own answer; the gate | |
| 229 | + | /// names the blocker instead. What the gate cannot say is which task is | |
| 230 | + | /// worth scheduling first, which is what this says. | |
| 231 | + | pub(crate) fn frees_marker(self) -> Option<Tag> { | |
| 232 | + | (!self.blocked && !self.in_cycle && self.unblocks > 0) | |
| 233 | + | .then(|| Tag::badge(format!("Unblocks {}", self.unblocks)).tone(Tone::Info)) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 117 | 237 | /// Every described screen's routes. | |
| 118 | 238 | #[must_use] | |
| 119 | 239 | pub fn router() -> Router<AppState> { |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | //! carried 19, plus 2 `escAttr()`, measured at `a76f6d8` where this module was | |
| 12 | 12 | //! added. The 15 was wrong when it was written and matches nothing. | |
| 13 | 13 | //! | |
| 14 | - | //! # This description is behind the shipped screen | |
| 14 | + | //! # This description was behind the shipped screen, and it was measured | |
| 15 | 15 | //! | |
| 16 | 16 | //! Measured 2026-08-15, and the reason the count above was worth chasing. | |
| 17 | 17 | //! `task-overview.js` is at 26 `esc()` and 7 `escAttr()` today. Two commits | |
| @@ -19,16 +19,16 @@ | |||
| 19 | 19 | //! `338aa9f` gave tasks a blocking graph, and the drawer grew a whole | |
| 20 | 20 | //! Dependencies section for it. Blockers and dependents with their edit | |
| 21 | 21 | //! controls, a "Blocked, N steps away" badge off `blockDepth`, an "unblocks N | |
| 22 | - | //! tasks" count, and a warning when the task sits on a cycle. | |
| 22 | + | //! tasks" count, and a warning when the task sits on a cycle. Nothing in this | |
| 23 | + | //! module said any of it. | |
| 23 | 24 | //! | |
| 24 | - | //! Nothing in this module says any of it. [`super::board`] describes the blocked | |
| 25 | - | //! and unblocks markers as tokens, so the vocabulary can carry the flat facts; | |
| 26 | - | //! it is the section, its lists and its two edit controls that are undescribed | |
| 27 | - | //! here. Whether that is a port or a vocabulary question is unmeasured. | |
| 25 | + | //! It says it now: [`dependencies_section`], and it took no vocabulary member, | |
| 26 | + | //! which is recorded there because "undescribable" was the live guess. | |
| 28 | 27 | //! | |
| 29 | 28 | //! Worth knowing generally: a described screen does not stop its JS counterpart | |
| 30 | - | //! from growing, and nothing checks the two against each other. This is the | |
| 31 | - | //! first time anyone asked, of any of the eleven ports. | |
| 29 | + | //! from growing, and nothing checks the two against each other. This was the | |
| 30 | + | //! first time anyone asked, of any of the eleven ports. The sweep over the other | |
| 31 | + | //! ten is in [the module above](super#the-ports-checked-against-their-counterparts). | |
| 32 | 32 | //! | |
| 33 | 33 | //! This is the first port with something the vocabulary is meant not to reach. | |
| 34 | 34 | //! The completion heatmap is a month grid of counts, and a description | |
| @@ -45,6 +45,8 @@ | |||
| 45 | 45 | //! - `POST /tasks/{id}/subtasks` — add one. | |
| 46 | 46 | //! - `POST /tasks/{id}/subtasks/{sub}/toggle` — tick or untick one. | |
| 47 | 47 | //! - `POST /tasks/{id}/notes` — add a note. | |
| 48 | + | //! - `POST /tasks/{id}/blockers` — draw an edge, carrying `blocker`. | |
| 49 | + | //! - `POST /tasks/{id}/dependencies/{other}/remove` — cut one, carrying `role`. | |
| 48 | 50 | //! | |
| 49 | 51 | //! Every described control reaches one of those, which is the standard the | |
| 50 | 52 | //! contacts port set. The one control left out rather than dangled is Edit: it | |
| @@ -58,8 +60,10 @@ | |||
| 58 | 60 | #![allow(clippy::needless_pass_by_value)] | |
| 59 | 61 | ||
| 60 | 62 | use chrono::{DateTime, Local, Utc}; | |
| 61 | - | use goingson_core::{Annotation, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession}; | |
| 62 | - | use quasi_router::screen::{Act, Field, Figure, Meter, Row, Tag}; | |
| 63 | + | use goingson_core::{ | |
| 64 | + | Annotation, LinkedTaskRef, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession, | |
| 65 | + | }; | |
| 66 | + | use quasi_router::screen::{Act, Choice, Field, Figure, Meter, Row, Tag}; | |
| 63 | 67 | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 64 | 68 | ||
| 65 | 69 | use crate::commands::{StreakInfo, compute_streak}; | |
| @@ -488,6 +492,205 @@ | |||
| 488 | 492 | out | |
| 489 | 493 | } | |
| 490 | 494 | ||
| 495 | + | /// Which end of an edge a row is looking at. | |
| 496 | + | /// | |
| 497 | + | /// Removing is always expressed as `(blocked, blocker)` regardless of which | |
| 498 | + | /// list the user is reading, so the row has to say which side it is on. It | |
| 499 | + | /// travels as a payload value rather than as two ids in the address, because | |
| 500 | + | /// the address names the task whose screen answers and that is the viewed task | |
| 501 | + | /// either way. | |
| 502 | + | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 503 | + | enum Role { | |
| 504 | + | /// The other task blocks this one. | |
| 505 | + | Blocker, | |
| 506 | + | /// The other task waits on this one. | |
| 507 | + | Dependent, | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | impl Role { | |
| 511 | + | const fn as_str(self) -> &'static str { | |
| 512 | + | match self { | |
| 513 | + | Self::Blocker => "blocker", | |
| 514 | + | Self::Dependent => "dependent", | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | fn from_payload(raw: &str) -> Result<Self, RouteError> { | |
| 519 | + | match raw { | |
| 520 | + | "blocker" => Ok(Self::Blocker), | |
| 521 | + | "dependent" => Ok(Self::Dependent), | |
| 522 | + | _ => Err(RouteError::not_found("no such side of an edge")), | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | /// One end of an edge. | |
| 528 | + | /// | |
| 529 | + | /// Satisfied edges are drawn, greyed rather than hidden, which is the JS's own | |
| 530 | + | /// call and worth keeping: a completed blocker is the record of what this task | |
| 531 | + | /// waited for, and dropping it would make a finished chain look like it never | |
| 532 | + | /// existed. The greying is a tone here, because "satisfied" is a fact about the | |
| 533 | + | /// edge and `.is-satisfied` is one host's way of drawing it. | |
| 534 | + | fn dependency_row(viewed: TaskId, entry: &LinkedTaskRef, role: Role) -> Row { | |
| 535 | + | let satisfied = entry.is_satisfied(); | |
| 536 | + | let mut row = | |
| 537 | + | Row::new(&entry.title).token(Tag::badge(entry.status.as_str()).tone(if satisfied { | |
| 538 | + | makeover_layout::Tone::Neutral | |
| 539 | + | } else { | |
| 540 | + | status_tone(&entry.status) | |
| 541 | + | })); | |
| 542 | + | if let Some(project) = &entry.project_name { | |
| 543 | + | row = row.meta(project); | |
| 544 | + | } | |
| 545 | + | row.act(Act::new( | |
| 546 | + | "Remove", | |
| 547 | + | Action::post(format!("/tasks/{viewed}/dependencies/{}/remove", entry.id)) | |
| 548 | + | .with("role", role.as_str()), | |
| 549 | + | )) | |
| 550 | + | .activate(Action::get(format!("/tasks/{}", entry.id))) | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | /// The dependencies section: what blocks this task and what waits on it. | |
| 554 | + | /// | |
| 555 | + | /// # The sixth finding, and it is not about this section | |
| 556 | + | /// | |
| 557 | + | /// This section did not exist until 2026-08-15, and the reason it did not is | |
| 558 | + | /// the finding. `338aa9f` added the whole blocking graph to `task-overview.js` | |
| 559 | + | /// **after** this screen was ported, and nothing checks a described screen | |
| 560 | + | /// against the counterpart it was ported from, so the description simply went | |
| 561 | + | /// on describing the screen as it had been. `tasks.rs` mentioned block or | |
| 562 | + | /// depend zero times while its counterpart mentioned them 34. | |
| 563 | + | /// | |
| 564 | + | /// Nothing here needed the vocabulary to grow, which is the other half of the | |
| 565 | + | /// finding: the gap was never expressiveness. Two lists of rows, a marker, a | |
| 566 | + | /// count, and a select of candidate tasks. | |
| 567 | + | /// | |
| 568 | + | /// # The picker is a field, not a modal | |
| 569 | + | /// | |
| 570 | + | /// `pickBlocker` opens a modal listing every candidate. A modal is an | |
| 571 | + | /// arrangement and the router answers one screen at a time (the fifth finding, | |
| 572 | + | /// on [`screen`]), so what is described is the *choice*: a select of the tasks | |
| 573 | + | /// that could block this one, submitted as an ordinary form. A webview may | |
| 574 | + | /// still draw it as the modal it draws today. Same answer the board's drag got. | |
| 575 | + | /// | |
| 576 | + | /// The candidate filter is the JS's: not this task, not already a blocker, not | |
| 577 | + | /// deleted. The repository is still the authority and refuses a cycle-closing | |
| 578 | + | /// edge naming the chain, which [`add_blocker`] passes through verbatim. | |
| 579 | + | fn dependencies_section( | |
| 580 | + | task: &Task, | |
| 581 | + | blockers: &[LinkedTaskRef], | |
| 582 | + | dependents: &[LinkedTaskRef], | |
| 583 | + | candidates: Vec<Choice>, | |
| 584 | + | ) -> Vec<Node> { | |
| 585 | + | let mut out = vec![Node::section("Dependencies")]; | |
| 586 | + | ||
| 587 | + | // Where the task sits, said once and plainly. Unlike the row markers on the | |
| 588 | + | // board and the dashboard, this section says "Ready" out loud: it is the | |
| 589 | + | // screen the reader came to for the answer, so having no badge would read | |
| 590 | + | // as the section failing to say rather than as the ordinary case. | |
| 591 | + | out.push(Node::Token(if task.graph.in_cycle { | |
| 592 | + | Tag::badge("In a cycle").tone(makeover_layout::Tone::Danger) | |
| 593 | + | } else if task.is_blocked() { | |
| 594 | + | Tag::badge(format!( | |
| 595 | + | "Blocked, {}", | |
| 596 | + | if task.graph.block_depth == 1 { | |
| 597 | + | "1 step away".to_owned() | |
| 598 | + | } else { | |
| 599 | + | format!("{} steps away", task.graph.block_depth) | |
| 600 | + | } | |
| 601 | + | )) | |
| 602 | + | .tone(makeover_layout::Tone::Warning) | |
| 603 | + | } else { | |
| 604 | + | Tag::badge("Ready").tone(makeover_layout::Tone::Success) | |
| 605 | + | })); | |
| 606 | + | if task.graph.unblocks_count > 0 { | |
| 607 | + | let n = task.graph.unblocks_count; | |
| 608 | + | out.push(Node::text(format!( | |
| 609 | + | "unblocks {n} task{}", | |
| 610 | + | if n == 1 { "" } else { "s" } | |
| 611 | + | ))); | |
| 612 | + | } | |
| 613 | + | if task.graph.in_cycle { | |
| 614 | + | out.push(Node::Text { | |
| 615 | + | text: "This task sits on a dependency cycle, so it can never become available. \ | |
| 616 | + | Remove one of the edges below to break it." | |
| 617 | + | .to_owned(), | |
| 618 | + | tone: makeover_layout::Tone::Danger, | |
| 619 | + | }); | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | if !blockers.is_empty() { | |
| 623 | + | out.push(Node::Heading { | |
| 624 | + | level: makeover_layout::Heading::Subsection, | |
| 625 | + | text: "Blocked by".to_owned(), | |
| 626 | + | }); | |
| 627 | + | out.push(Node::list( | |
| 628 | + | blockers | |
| 629 | + | .iter() | |
| 630 | + | .map(|entry| dependency_row(task.id, entry, Role::Blocker)), | |
| 631 | + | )); | |
| 632 | + | } | |
| 633 | + | if !dependents.is_empty() { | |
| 634 | + | out.push(Node::Heading { | |
| 635 | + | level: makeover_layout::Heading::Subsection, | |
| 636 | + | text: "Blocks".to_owned(), | |
| 637 | + | }); | |
| 638 | + | out.push(Node::list( | |
| 639 | + | dependents | |
| 640 | + | .iter() | |
| 641 | + | .map(|entry| dependency_row(task.id, entry, Role::Dependent)), | |
| 642 | + | )); | |
| 643 | + | } | |
| 644 | + | if blockers.is_empty() && dependents.is_empty() { | |
| 645 | + | out.push(Node::text( | |
| 646 | + | "Nothing blocks this task and nothing waits on it.", | |
| 647 | + | )); | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | // A completed task is not offered a new blocker, the JS's own condition, | |
| 651 | + | // and neither is one with nothing left to depend on: an empty select is a | |
| 652 | + | // control that cannot be used, which is the toast the JS shows instead. | |
| 653 | + | if task.status != TaskStatus::Completed && !candidates.is_empty() { | |
| 654 | + | out.push(Node::Form { | |
| 655 | + | action: Action::post(format!("/tasks/{}/blockers", task.id)), | |
| 656 | + | submit: "Add blocker".to_owned(), | |
| 657 | + | fields: vec![ | |
| 658 | + | Field::select("blocker", "Must be completed first", candidates) | |
| 659 | + | .hint("This task stays unavailable until that one is done."), | |
| 660 | + | ], | |
| 661 | + | }); | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | out | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | /// The tasks that could block this one. | |
| 668 | + | /// | |
| 669 | + | /// `pickBlocker`'s filter, moved to where the data is. The JS fetches every | |
| 670 | + | /// task and filters in the browser; here the same rule runs before anything is | |
| 671 | + | /// described, so a screen never offers a choice the repository would refuse. | |
| 672 | + | fn blocker_candidates( | |
| 673 | + | state: &AppState, | |
| 674 | + | task: &Task, | |
| 675 | + | blockers: &[LinkedTaskRef], | |
| 676 | + | ) -> Result<Vec<Choice>, RouteError> { | |
| 677 | + | let already: std::collections::HashSet<TaskId> = blockers.iter().map(|b| b.id).collect(); | |
| 678 | + | Ok(state | |
| 679 | + | .tasks | |
| 680 | + | .list_all(DESKTOP_USER_ID) | |
| 681 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 682 | + | .into_iter() | |
| 683 | + | .filter(|other| other.id != task.id && !already.contains(&other.id)) | |
| 684 | + | .map(|other| { | |
| 685 | + | let label = match &other.project_name { | |
| 686 | + | Some(project) => format!("{} ({project})", other.title), | |
| 687 | + | None => other.title.clone(), | |
| 688 | + | }; | |
| 689 | + | Choice::new(other.id.to_string(), label) | |
| 690 | + | }) | |
| 691 | + | .collect()) | |
| 692 | + | } | |
| 693 | + | ||
| 491 | 694 | /// The whole screen. | |
| 492 | 695 | /// | |
| 493 | 696 | /// Built here rather than inside the route because every write answers with it, | |
| @@ -509,6 +712,15 @@ | |||
| 509 | 712 | .list_time_sessions(id, DESKTOP_USER_ID) | |
| 510 | 713 | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 511 | 714 | let streak = streak_for(state, &task)?; | |
| 715 | + | let blockers = state | |
| 716 | + | .tasks | |
| 717 | + | .list_blockers(DESKTOP_USER_ID, id) | |
| 718 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 719 | + | let dependents = state | |
| 720 | + | .tasks | |
| 721 | + | .list_dependents(DESKTOP_USER_ID, id) | |
| 722 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 723 | + | let candidates = blocker_candidates(state, &task, &blockers)?; | |
| 512 | 724 | ||
| 513 | 725 | let mut band = Slot::new("task-band", RegionKind::Band).with(Node::page(&task.title)); | |
| 514 | 726 | if task.status != TaskStatus::Completed { | |
| @@ -537,6 +749,12 @@ | |||
| 537 | 749 | if !task.subtasks.is_empty() || task.status != TaskStatus::Completed { | |
| 538 | 750 | pane = pane.extend(subtasks_section(&task)); | |
| 539 | 751 | } | |
| 752 | + | pane = pane.extend(dependencies_section( | |
| 753 | + | &task, | |
| 754 | + | &blockers, | |
| 755 | + | &dependents, | |
| 756 | + | candidates, | |
| 757 | + | )); | |
| 540 | 758 | pane = pane.extend(time_section(&task, &sessions)); | |
| 541 | 759 | pane = pane.extend(notes_section(&task)); | |
| 542 | 760 | ||
| @@ -661,11 +879,70 @@ | |||
| 661 | 879 | wrote(state, id) | |
| 662 | 880 | } | |
| 663 | 881 | ||
| 882 | + | /// Draw an edge: the picked task must finish before this one starts. | |
| 883 | + | /// | |
| 884 | + | /// A cycle-closing edge is refused by the repository with a message naming the | |
| 885 | + | /// chain already in the way, and that message is what the user sees. It is the | |
| 886 | + | /// only useful thing to say here, so it is passed through rather than replaced | |
| 887 | + | /// with a generic failure — the same call `addBlocker` makes, and a toast for | |
| 888 | + | /// the same reason: the screen is fine, one submission was not. | |
| 889 | + | fn add_blocker(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 890 | + | let id = task_id(&request)?; | |
| 891 | + | let raw = request.payload.get("blocker").unwrap_or_default(); | |
| 892 | + | // An empty submit is the JS's early return: the user pressed the button | |
| 893 | + | // without picking, and the screen should simply not change. | |
| 894 | + | if raw.trim().is_empty() { | |
| 895 | + | return wrote(state, id); | |
| 896 | + | } | |
| 897 | + | let blocker = TaskId::from( | |
| 898 | + | uuid::Uuid::parse_str(raw.trim()).map_err(|_| RouteError::not_found("not a task id"))?, | |
| 899 | + | ); | |
| 900 | + | state | |
| 901 | + | .tasks | |
| 902 | + | .add_dependency(DESKTOP_USER_ID, id, blocker) | |
| 903 | + | .map_err(|error| RouteError::conflict(error.to_string()).as_toast())?; | |
| 904 | + | wrote(state, id) | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | /// Cut an edge, from either side of it. | |
| 908 | + | fn remove_dependency( | |
| 909 | + | state: &AppState, | |
| 910 | + | request: quasi_router::Request, | |
| 911 | + | ) -> Result<Response, RouteError> { | |
| 912 | + | let id = task_id(&request)?; | |
| 913 | + | let raw = request | |
| 914 | + | .captures | |
| 915 | + | .get("other") | |
| 916 | + | .ok_or_else(|| RouteError::not_found("no task id"))?; | |
| 917 | + | let other = TaskId::from( | |
| 918 | + | uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, | |
| 919 | + | ); | |
| 920 | + | let (blocked, blocker) = | |
| 921 | + | match Role::from_payload(request.payload.get("role").unwrap_or_default()) { | |
| 922 | + | Ok(Role::Blocker) => (id, other), | |
| 923 | + | Ok(Role::Dependent) => (other, id), | |
| 924 | + | Err(error) => return Err(error), | |
| 925 | + | }; | |
| 926 | + | let removed = state | |
| 927 | + | .tasks | |
| 928 | + | .remove_dependency(DESKTOP_USER_ID, blocked, blocker) | |
| 929 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 930 | + | if !removed { | |
| 931 | + | return Err(RouteError::not_found("no such dependency")); | |
| 932 | + | } | |
| 933 | + | // The viewed task's screen, not the edge's other end: the address names | |
| 934 | + | // where the user is standing, which is what makes one route serve both | |
| 935 | + | // lists. | |
| 936 | + | wrote(state, id) | |
| 937 | + | } | |
| 938 | + | ||
| 664 | 939 | /// The task overview's routes. | |
| 665 | 940 | #[must_use] | |
| 666 | 941 | pub fn routes(router: Router<AppState>) -> Router<AppState> { | |
| 667 | 942 | router | |
| 668 | 943 | .get("/tasks/{id}", overview) | |
| 944 | + | .post("/tasks/{id}/blockers", add_blocker) | |
| 945 | + | .post("/tasks/{id}/dependencies/{other}/remove", remove_dependency) | |
| 669 | 946 | .post("/tasks/{id}/complete", complete) | |
| 670 | 947 | .post("/tasks/{id}/delete", remove) | |
| 671 | 948 | .post("/tasks/{id}/subtasks", add_subtask) |
| @@ -218,3 +218,35 @@ | |||
| 218 | 218 | // reads as a broken board rather than an empty one. | |
| 219 | 219 | assert_eq!(markup.matches("No tasks").count(), 3, "{markup}"); | |
| 220 | 220 | } | |
| 221 | + | ||
| 222 | + | #[tokio::test] | |
| 223 | + | async fn a_card_says_whether_the_task_is_available() { | |
| 224 | + | let state = state().await; | |
| 225 | + | let blocker = task(&state, "Do this first"); | |
| 226 | + | let blocked = task(&state, "Then this"); | |
| 227 | + | state | |
| 228 | + | .tasks | |
| 229 | + | .add_dependency(DESKTOP_USER_ID, blocked, blocker) | |
| 230 | + | .unwrap(); | |
| 231 | + | ||
| 232 | + | let markup = board(&state); | |
| 233 | + | ||
| 234 | + | // The blocked card says so, and the one that frees it says what finishing | |
| 235 | + | // it buys. `tasks-kanban.js` has drawn both since `0df3488`; this port was | |
| 236 | + | // written after that and said neither. | |
| 237 | + | assert!(markup.contains("Blocked"), "{markup}"); | |
| 238 | + | assert!(markup.contains("Unblocks 1"), "{markup}"); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[tokio::test] | |
| 242 | + | async fn an_ordinary_card_carries_no_dependency_marker() { | |
| 243 | + | let state = state().await; | |
| 244 | + | task(&state, "Nothing in its way"); | |
| 245 | + | ||
| 246 | + | let markup = board(&state); | |
| 247 | + | ||
| 248 | + | // A ready task with nothing downstream is the ordinary case. Marking it | |
| 249 | + | // would put a badge on nearly every card. | |
| 250 | + | assert!(!markup.contains("Blocked"), "{markup}"); | |
| 251 | + | assert!(!markup.contains("Unblocks"), "{markup}"); | |
| 252 | + | } |
| @@ -210,3 +210,38 @@ | |||
| 210 | 210 | let markup = html(response); | |
| 211 | 211 | assert!(markup.contains("Standup"), "{markup}"); | |
| 212 | 212 | } | |
| 213 | + | ||
| 214 | + | /// A task due on the test day and not on the axis, which is what the pool holds. | |
| 215 | + | fn due_task(state: &AppState, title: &str) -> goingson_core::TaskId { | |
| 216 | + | let due = Local | |
| 217 | + | .from_local_datetime(&day().and_hms_opt(12, 0, 0).unwrap()) | |
| 218 | + | .unwrap() | |
| 219 | + | .with_timezone(&Utc); | |
| 220 | + | state | |
| 221 | + | .tasks | |
| 222 | + | .create( | |
| 223 | + | DESKTOP_USER_ID, | |
| 224 | + | goingson_core::NewTask::builder(title).due(due).build(), | |
| 225 | + | ) | |
| 226 | + | .unwrap() | |
| 227 | + | .id | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[tokio::test] | |
| 231 | + | async fn the_pool_says_which_task_is_worth_scheduling_first() { | |
| 232 | + | let state = state().await; | |
| 233 | + | let blocker = due_task(&state, "Do this first"); | |
| 234 | + | let blocked = due_task(&state, "Then this"); | |
| 235 | + | state | |
| 236 | + | .tasks | |
| 237 | + | .add_dependency(DESKTOP_USER_ID, blocked, blocker) | |
| 238 | + | .unwrap(); | |
| 239 | + | ||
| 240 | + | let markup = screen(&state); | |
| 241 | + | ||
| 242 | + | // The frees-work half only, which is `0df3488`'s reading and the pool's | |
| 243 | + | // own: the gate already refuses to offer a blocked task until its blockers | |
| 244 | + | // are in the day, so a "Blocked" badge here would contradict the plan. | |
| 245 | + | assert!(markup.contains("Unblocks 1"), "{markup}"); | |
| 246 | + | assert!(!markup.contains("Blocked"), "{markup}"); | |
| 247 | + | } |
| @@ -67,6 +67,12 @@ | |||
| 67 | 67 | /// bar rather than a node, so the 2026-08-08 ruling stands untouched. | |
| 68 | 68 | fn task_row(task: &Task) -> Row { | |
| 69 | 69 | let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str())); | |
| 70 | + | // Whether the task is available. `projects-render.js` grew this on | |
| 71 | + | // 2026-08-09 and this port, written the day before, did not have it; see | |
| 72 | + | // [`crate::quasi::Availability`] for why it is shared rather than redrawn. | |
| 73 | + | if let Some(marker) = crate::quasi::Availability::of(task).marker() { | |
| 74 | + | row = row.token(marker); | |
| 75 | + | } | |
| 70 | 76 | if task.subtask_count() > 0 { | |
| 71 | 77 | row = row.meter( | |
| 72 | 78 | Meter::new( |
| @@ -452,3 +452,173 @@ | |||
| 452 | 452 | let page = html(get(&state, &format!("/tasks/{}", task.id))); | |
| 453 | 453 | assert!(!page.contains(">Edit<")); | |
| 454 | 454 | } | |
| 455 | + | ||
| 456 | + | // The dependencies section. It exists because `338aa9f` added the blocking | |
| 457 | + | // graph to `task-overview.js` after this screen was ported and nothing checked | |
| 458 | + | // the two against each other; these are the assertions that make the drift a | |
| 459 | + | // test failure rather than something noticed a second time by hand. | |
| 460 | + | ||
| 461 | + | #[tokio::test] | |
| 462 | + | async fn a_task_with_no_edges_says_so_and_reads_as_ready() { | |
| 463 | + | let state = state().await; | |
| 464 | + | let task = add(&state, "Standalone"); | |
| 465 | + | ||
| 466 | + | let page = html(get(&state, &format!("/tasks/{}", task.id))); | |
| 467 | + | ||
| 468 | + | assert!(page.contains("Dependencies"), "{page}"); | |
| 469 | + | assert!(page.contains("Ready"), "{page}"); | |
| 470 | + | assert!( | |
| 471 | + | page.contains("Nothing blocks this task and nothing waits on it."), | |
| 472 | + | "{page}" | |
| 473 | + | ); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[tokio::test] | |
| 477 | + | async fn a_blocked_task_says_how_far_away_it_is_and_names_what_it_waits_on() { | |
| 478 | + | let state = state().await; | |
| 479 | + | let blocker = add(&state, "Do this first"); | |
| 480 | + | let blocked = add(&state, "Then this"); | |
| 481 | + | state | |
| 482 | + | .tasks | |
| 483 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 484 | + | .unwrap(); | |
| 485 | + | ||
| 486 | + | let page = html(get(&state, &format!("/tasks/{}", blocked.id))); | |
| 487 | + | ||
| 488 | + | // The depth, which is the fact `blockDepth` carries and a bare "Blocked" | |
| 489 | + | // does not. | |
| 490 | + | assert!(page.contains("Blocked, 1 step away"), "{page}"); | |
| 491 | + | assert!(page.contains("Blocked by"), "{page}"); | |
| 492 | + | assert!(page.contains("Do this first"), "{page}"); | |
| 493 | + | ||
| 494 | + | // And the other end of the same edge, from the other side. | |
| 495 | + | let upstream = html(get(&state, &format!("/tasks/{}", blocker.id))); | |
| 496 | + | assert!(upstream.contains("Blocks"), "{upstream}"); | |
| 497 | + | assert!(upstream.contains("unblocks 1 task"), "{upstream}"); | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | #[tokio::test] | |
| 501 | + | async fn a_satisfied_edge_is_drawn_rather_than_hidden() { | |
| 502 | + | // A completed blocker is the record of what this task waited for. Dropping | |
| 503 | + | // it from the view would make a finished chain look like it never existed. | |
| 504 | + | let state = state().await; | |
| 505 | + | let blocker = add(&state, "Already done"); | |
| 506 | + | let blocked = add(&state, "Now available"); | |
| 507 | + | state | |
| 508 | + | .tasks | |
| 509 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 510 | + | .unwrap(); | |
| 511 | + | state.tasks.complete(blocker.id, DESKTOP_USER_ID).unwrap(); | |
| 512 | + | ||
| 513 | + | let page = html(get(&state, &format!("/tasks/{}", blocked.id))); | |
| 514 | + | ||
| 515 | + | assert!(page.contains("Ready"), "{page}"); | |
| 516 | + | assert!(page.contains("Already done"), "{page}"); | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | #[tokio::test] | |
| 520 | + | async fn adding_a_blocker_draws_the_edge_and_answers_the_screen() { | |
| 521 | + | let state = state().await; | |
| 522 | + | let blocker = add(&state, "Prerequisite"); | |
| 523 | + | let blocked = add(&state, "Dependent work"); | |
| 524 | + | ||
| 525 | + | let page = html(post( | |
| 526 | + | &state, | |
| 527 | + | &format!("/tasks/{}/blockers", blocked.id), | |
| 528 | + | Params::new().with("blocker", blocker.id.to_string()), | |
| 529 | + | )); | |
| 530 | + | ||
| 531 | + | assert!(page.contains("Blocked, 1 step away"), "{page}"); | |
| 532 | + | assert!(page.contains("Prerequisite"), "{page}"); | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | #[tokio::test] | |
| 536 | + | async fn an_edge_that_would_close_a_cycle_is_refused_with_the_chain() { | |
| 537 | + | // The repository is the authority and its message names the chain already | |
| 538 | + | // in the way, which is the only useful thing to say. The described screen | |
| 539 | + | // passes it through rather than replacing it with a generic failure. | |
| 540 | + | let state = state().await; | |
| 541 | + | let first = add(&state, "First"); | |
| 542 | + | let second = add(&state, "Second"); | |
| 543 | + | state | |
| 544 | + | .tasks | |
| 545 | + | .add_dependency(DESKTOP_USER_ID, second.id, first.id) | |
| 546 | + | .unwrap(); | |
| 547 | + | ||
| 548 | + | let error = router() | |
| 549 | + | .handle( | |
| 550 | + | &state, | |
| 551 | + | Request::post(format!("/tasks/{}/blockers", first.id)) | |
| 552 | + | .sending(Params::new().with("blocker", second.id.to_string())), | |
| 553 | + | ) | |
| 554 | + | .expect_err("a cycle is refused"); | |
| 555 | + | ||
| 556 | + | assert_eq!(error.class, quasi_router::error::Class::Conflict); | |
| 557 | + | assert!(!error.message.is_empty(), "{error}"); | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | #[tokio::test] | |
| 561 | + | async fn an_edge_can_be_cut_from_either_side_of_it() { | |
| 562 | + | let state = state().await; | |
| 563 | + | let blocker = add(&state, "Upstream"); | |
| 564 | + | let blocked = add(&state, "Downstream"); | |
| 565 | + | state | |
| 566 | + | .tasks | |
| 567 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 568 | + | .unwrap(); | |
| 569 | + | ||
| 570 | + | // From the blocked task's screen, where the other end is a blocker. | |
| 571 | + | let page = html(post( | |
| 572 | + | &state, | |
| 573 | + | &format!("/tasks/{}/dependencies/{}/remove", blocked.id, blocker.id), | |
| 574 | + | Params::new().with("role", "blocker"), | |
| 575 | + | )); | |
| 576 | + | assert!(page.contains("Ready"), "{page}"); | |
| 577 | + | ||
| 578 | + | // And back the other way, cut from the blocker's screen this time, where | |
| 579 | + | // the same edge reads as a dependent. | |
| 580 | + | state | |
| 581 | + | .tasks | |
| 582 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 583 | + | .unwrap(); | |
| 584 | + | let page = html(post( | |
| 585 | + | &state, | |
| 586 | + | &format!("/tasks/{}/dependencies/{}/remove", blocker.id, blocked.id), | |
| 587 | + | Params::new().with("role", "dependent"), | |
| 588 | + | )); | |
| 589 | + | // The blocker's own screen, which is where the user was standing. | |
| 590 | + | assert!(page.contains("Upstream"), "{page}"); | |
| 591 | + | assert!( | |
| 592 | + | page.contains("Nothing blocks this task and nothing waits on it."), | |
| 593 | + | "{page}" | |
| 594 | + | ); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | #[tokio::test] | |
| 598 | + | async fn a_completed_task_is_not_offered_a_new_blocker() { | |
| 599 | + | let state = state().await; | |
| 600 | + | let task = add(&state, "Done with it"); | |
| 601 | + | let _other = add(&state, "Some other task"); | |
| 602 | + | state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap(); | |
| 603 | + | ||
| 604 | + | let page = html(get(&state, &format!("/tasks/{}", task.id))); | |
| 605 | + | ||
| 606 | + | assert!(page.contains("Dependencies"), "{page}"); | |
| 607 | + | assert!(!page.contains("Add blocker"), "{page}"); | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | #[tokio::test] | |
| 611 | + | async fn a_blockers_title_cannot_become_markup() { | |
| 612 | + | let state = state().await; | |
| 613 | + | let blocker = add(&state, "<script>alert('x')</script>"); | |
| 614 | + | let blocked = add(&state, "Ordinary"); | |
| 615 | + | state | |
| 616 | + | .tasks | |
| 617 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 618 | + | .unwrap(); | |
| 619 | + | ||
| 620 | + | let page = html(get(&state, &format!("/tasks/{}", blocked.id))); | |
| 621 | + | ||
| 622 | + | assert!(!page.contains("<script>"), "{page}"); | |
| 623 | + | assert!(page.contains("<script>"), "{page}"); | |
| 624 | + | } |
| @@ -543,3 +543,37 @@ | |||
| 543 | 543 | assert!(!page.contains("<script>alert")); | |
| 544 | 544 | assert!(page.contains("<script>")); | |
| 545 | 545 | } | |
| 546 | + | ||
| 547 | + | #[tokio::test] | |
| 548 | + | async fn a_linked_task_says_whether_it_is_available() { | |
| 549 | + | // `projects-render.js` grew the two markers on 2026-08-09 (`0df3488`), | |
| 550 | + | // after this screen was described, and nothing checked the port against | |
| 551 | + | // the counterpart it was ported from. This is that check. | |
| 552 | + | let state = state().await; | |
| 553 | + | let project = project(&state); | |
| 554 | + | let blocker = state | |
| 555 | + | .tasks | |
| 556 | + | .create( | |
| 557 | + | DESKTOP_USER_ID, | |
| 558 | + | NewTask::builder("Do this first") | |
| 559 | + | .project_id(project) | |
| 560 | + | .build(), | |
| 561 | + | ) | |
| 562 | + | .unwrap(); | |
| 563 | + | let blocked = state | |
| 564 | + | .tasks | |
| 565 | + | .create( | |
| 566 | + | DESKTOP_USER_ID, | |
| 567 | + | NewTask::builder("Then this").project_id(project).build(), | |
| 568 | + | ) | |
| 569 | + | .unwrap(); | |
| 570 | + | state | |
| 571 | + | .tasks | |
| 572 | + | .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) | |
| 573 | + | .unwrap(); | |
| 574 | + | ||
| 575 | + | let page = dashboard(&state, project); | |
| 576 | + | ||
| 577 | + | assert!(page.contains("Blocked"), "{page}"); | |
| 578 | + | assert!(page.contains("Unblocks 1"), "{page}"); | |
| 579 | + | } |