Skip to main content

max / goingson

Put the detail back behind the board's dependency badges quasicoherent 436bc223 gave Tag a hint, which is what Availability was waiting for: the shipped badges carried the block depth, the freed count's wording and the cycle repair instruction in a title, and the three ports said the label and dropped it. Each hint is a longer form of a label already on the card, which is what makes it safe for a renderer to drop -- quasi-tui does.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 17:51 UTC
Signed with PGP, not checked
Commit: 0e2cd561c86a79cb30b53ad9873f0732d57f36d4
Parent: d972156
2 files changed, +70 insertions, -11 deletions
@@ -123,18 +123,30 @@
123 123 /// ports (the dashboard, the day view, the board) each said the fact its own
124 124 /// JS counterpart said at port time, and none said this one.
125 125 ///
126 - /// # What the description cannot carry
126 + /// # The detail behind each label
127 127 ///
128 - /// The shipped badges put the detail in a `title`: the block depth behind
129 - /// "Blocked", the freed count's wording behind "Unblocks N", the repair
130 - /// instruction behind "Cycle". A [`Tag`] has a label and a tone and no hint, so
131 - /// the labels here are the shipped visible text and the detail has nowhere to
132 - /// go. Whether tokens should carry a hint is a vocabulary question and is not
133 - /// answered by this type quietly dropping it.
128 + /// The shipped badges put it in a `title`: the block depth behind "Blocked",
129 + /// the freed count's wording behind "Unblocks N", the repair instruction behind
130 + /// "Cycle". [`Tag`] carried a label and a tone and no hint until quasicoherent
131 + /// `436bc223`, so the described cards said the short thing and dropped the long
132 + /// one. They carry both now, through [`Tag::hinted`].
133 + ///
134 + /// A renderer may still drop a hint -- quasi-tui does, having nowhere to put
135 + /// one -- so nothing here may live only in a hint. Each of the three is a
136 + /// longer form of a label that is already on screen, which is what makes that
137 + /// safe: the badge alone is the fact, and the hint is the same fact said at
138 + /// length.
134 139 #[derive(Clone, Copy)]
135 140 pub(crate) struct Availability {
136 141 /// Something unfinished is in this task's way.
137 142 blocked: bool,
143 + /// How many sequential steps stand between this task and being startable.
144 + ///
145 + /// The longest chain, not the shortest, because a task waits for every
146 + /// blocker it has. Read for the "Blocked" hint and nothing else, which is
147 + /// why it is not itself a badge: a number on a card competes with the
148 + /// label, and the label is what a reader scans for.
149 + depth: u32,
138 150 /// It sits on a cycle, so it can never open.
139 151 in_cycle: bool,
140 152 /// How many tasks finishing this one would free.
@@ -146,6 +158,7 @@
146 158 pub(crate) fn of(task: &Task) -> Self {
147 159 Self {
148 160 blocked: task.is_blocked(),
161 + depth: task.graph.block_depth,
149 162 in_cycle: task.graph.in_cycle,
150 163 unblocks: task.graph.unblocks_count,
151 164 }
@@ -159,6 +172,7 @@
159 172 pub(crate) fn reported(task: &crate::commands::TaskResponse) -> Self {
160 173 Self {
161 174 blocked: task.is_blocked,
175 + depth: task.block_depth,
162 176 in_cycle: task.in_cycle,
163 177 unblocks: task.unblocks_count,
164 178 }
@@ -172,10 +186,18 @@
172 186 /// nothing downstream is the ordinary case and carries nothing at all.
173 187 pub(crate) fn marker(self) -> Option<Tag> {
174 188 if self.in_cycle {
175 - return Some(Tag::badge("Cycle").tone(Tone::Danger));
189 + return Some(
190 + Tag::badge("Cycle")
191 + .tone(Tone::Danger)
192 + .hinted("On a dependency cycle, so it can never open. Remove an edge."),
193 + );
176 194 }
177 195 if self.blocked {
178 - return Some(Tag::badge("Blocked").tone(Tone::Warning));
196 + return Some(
197 + Tag::badge("Blocked")
198 + .tone(Tone::Warning)
199 + .hinted(steps_away(self.depth)),
200 + );
179 201 }
180 202 self.frees_marker()
181 203 }
@@ -188,8 +210,32 @@
188 210 /// names the blocker instead. What the gate cannot say is which task is
189 211 /// worth scheduling first, which is what this says.
190 212 pub(crate) fn frees_marker(self) -> Option<Tag> {
191 - (!self.blocked && !self.in_cycle && self.unblocks > 0)
192 - .then(|| Tag::badge(format!("Unblocks {}", self.unblocks)).tone(Tone::Info))
213 + (!self.blocked && !self.in_cycle && self.unblocks > 0).then(|| {
214 + Tag::badge(format!("Unblocks {}", self.unblocks))
215 + .tone(Tone::Info)
216 + .hinted(format!(
217 + "Finishing this frees {}.",
218 + match self.unblocks {
219 + 1 => "1 other task".to_owned(),
220 + many => format!("{many} other tasks"),
221 + }
222 + ))
223 + })
224 + }
225 + }
226 +
227 + /// How far a blocked task is from being startable, in words.
228 + ///
229 + /// The depth is the longest chain ahead of the task, so "1 step away" means one
230 + /// completion and nothing else stands in the way. A depth of zero cannot reach
231 + /// here -- `is_blocked` is `block_depth > 0` -- and is said rather than
232 + /// asserted, because the two facts are cached columns that a merge could in
233 + /// principle disagree about, and a badge is not the place to panic.
234 + fn steps_away(depth: u32) -> String {
235 + match depth {
236 + 0 => "Waiting on something unfinished.".to_owned(),
237 + 1 => "1 step away: one task has to finish first.".to_owned(),
238 + many => format!("{many} steps away, counting the longest chain of blockers."),
193 239 }
194 240 }
195 241
@@ -249,6 +249,19 @@
249 249 // written after that and said neither.
250 250 assert!(markup.contains("Blocked"), "{markup}");
251 251 assert!(markup.contains("Unblocks 1"), "{markup}");
252 +
253 + // And the detail behind each label, which `tasks-kanban.js` carried in a
254 + // `title` and the port dropped until quasicoherent `436bc223` gave `Tag` a
255 + // hint. Both are longer forms of a label already on the card, so a renderer
256 + // that drops them loses nothing but the length.
257 + assert!(
258 + markup.contains("title=\"1 step away: one task has to finish first.\""),
259 + "{markup}"
260 + );
261 + assert!(
262 + markup.contains("title=\"Finishing this frees 1 other task.\""),
263 + "{markup}"
264 + );
252 265 }
253 266
254 267 #[tokio::test]