Skip to main content

max / goingson

Check every port against the screen it was ported from The task overview had been describing a screen that grew a whole Dependencies section after the port, and the finding was never that one module: nothing checks a described screen against its JS counterpart, and a described screen does not stop that counterpart from growing. Swept all ten. Three had drifted and all three by one commit, 0df3488, which added the dependency markers to the board card, the dashboard card and the day plan pool and touched no description. The board and the day view were ported after it and still did not carry them, so this is not only JS-grew-later. The markers are one Availability type in the module above rather than three copies, which is the same reason the JS calls the task row's own renderers: a second copy is how the surfaces drifted apart to begin with. The day plan takes only the frees-work half, because its gate already refuses to offer a blocked task until the blockers are in the day. The overview's Dependencies section is described now: both lists, the depth badge, the cycle warning, cutting an edge from either side, and the blocker picker as a select rather than a modal. It needed no vocabulary member, which is worth recording since "undescribable" was the standing guess. The sweep itself is a person running git log against two paths. A check that fails when a JS file moves without its described module is witchbroom-shaped work and is not here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 22:33 UTC
Signed with PGP, not checked
Commit: a050bb6ca4a81887340f2927d3f6dca6ace9711e
Parent: a075ea5
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("&lt;script&gt;"), "{page}");
624 + }
@@ -543,3 +543,37 @@
543 543 assert!(!page.contains("<script>alert"));
544 544 assert!(page.contains("&lt;script&gt;"));
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 + }