Skip to main content

max / goingson

Draw the two proportions the task overview was only counting The finding this port filed as d0b58239 closed: makeover-layout 0.10.0 named a proportion, so the subtask rollup and the time estimate get bars again instead of numbers concatenated into their headings. The time estimate is the site that decided the member's shape. It is the one place in either app where the numerator can exceed the denominator, and Task::time_progress clamps that away; the description carries 45 and 30, so the bar reports full and the over-run survives in data-over and in the accessible name. Two of the seven determinate sites, not all of them. The other five sit in rows, and a Row holds no nodes by the ruling that closed the token finding, so they keep their meta text. Filed as da5666ae with the row sites as its evidence. 25822137 closes with them, against a member. Its second consumer was this file's task description and its first was a project card wanting rich text in a row part, which the same ruling refuses outright — leaving one standalone consumer in one app, and one consumer is not evidence. Both call sites now say so rather than reading as pending.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 13:03 UTC
Signed with PGP, not checked
Commit: a3eec95cf6352b2a464c2406fd768b3a29084dc0
Parent: f72d8bc
6 files changed, +154 insertions, -45 deletions
M Cargo.lock +22 -9
@@ -2174,8 +2174,8 @@
2174 2174 "makeover",
2175 2175 "makeover-build",
2176 2176 "makeover-geometry",
2177 - "makeover-layout",
2178 - "makeover-webview",
2177 + "makeover-layout 0.10.0",
2178 + "makeover-webview 0.19.0",
2179 2179 "notify",
2180 2180 "notify-debouncer-mini",
2181 2181 "open",
@@ -3291,7 +3291,7 @@
3291 3291 dependencies = [
3292 3292 "makeover",
3293 3293 "makeover-geometry",
3294 - "makeover-webview",
3294 + "makeover-webview 0.18.0",
3295 3295 ]
3296 3296
3297 3297 [[package]]
@@ -3306,6 +3306,10 @@
3306 3306 source = "registry+https://github.com/rust-lang/crates.io-index"
3307 3307 checksum = "95b7ccafc965eb90f308b68d6fb00f6ff642a453b661f4c452cb20aeb60e3990"
3308 3308
3309 + [[package]]
3310 + name = "makeover-layout"
3311 + version = "0.10.0"
3312 +
3309 3313 [[package]]
3310 3314 name = "makeover-touch"
3311 3315 version = "0.5.0"
@@ -3313,7 +3317,7 @@
3313 3317 checksum = "f3228b2d380252585ab2fadd6146229bb17ba51fab1db5d8e6001d7e0e40fbb4"
3314 3318 dependencies = [
3315 3319 "makeover-geometry",
3316 - "makeover-layout",
3320 + "makeover-layout 0.9.0",
3317 3321 ]
3318 3322
3319 3323 [[package]]
@@ -3323,7 +3327,7 @@
3323 3327 checksum = "069fec2b3f017f3ae89c7b36eb8fab81dcfc6a722cfec97b80656df2fb8d9cf8"
3324 3328 dependencies = [
3325 3329 "makeover",
3326 - "makeover-layout",
3330 + "makeover-layout 0.9.0",
3327 3331 "ratatui",
3328 3332 ]
3329 3333
@@ -3334,7 +3338,16 @@
3334 3338 checksum = "b0bec4e3395087aa3777bea4aa559d1b90d53e170ae2012c77095b29be16a1c2"
3335 3339 dependencies = [
3336 3340 "makeover-geometry",
3337 - "makeover-layout",
3341 + "makeover-layout 0.9.0",
3342 + "makeover-touch",
3343 + ]
3344 +
3345 + [[package]]
3346 + name = "makeover-webview"
3347 + version = "0.19.0"
3348 + dependencies = [
3349 + "makeover-geometry",
3350 + "makeover-layout 0.10.0",
3338 3351 "makeover-touch",
3339 3352 ]
3340 3353
@@ -4531,7 +4544,7 @@
4531 4544 name = "quasi-router"
4532 4545 version = "0.1.0"
4533 4546 dependencies = [
4534 - "makeover-layout",
4547 + "makeover-layout 0.10.0",
4535 4548 ]
4536 4549
4537 4550 [[package]]
@@ -4548,8 +4561,8 @@
4548 4561 name = "quasi-webview"
4549 4562 version = "0.1.0"
4550 4563 dependencies = [
4551 - "makeover-layout",
4552 - "makeover-webview",
4564 + "makeover-layout 0.10.0",
4565 + "makeover-webview 0.19.0",
4553 4566 "quasi-http",
4554 4567 "quasi-router",
4555 4568 ]
@@ -20,8 +20,8 @@
20 20 # The table CSS is generated here too: the columns are this app's, so the
21 21 # shared helper cannot know them, but the tracks and the narrowing rules come
22 22 # from the description rather than from hand-written nth-child cuts.
23 - makeover-webview = "0.18"
24 - makeover-layout = "0.9"
23 + makeover-webview = "0.19.0"
24 + makeover-layout = "0.10.0"
25 25 # Width. Direct rather than through makeover-webview, because the narrow table
26 26 # pass keys off SizeClass::Compact and a boundary reached transitively is a
27 27 # boundary nobody pinned.
@@ -110,8 +110,8 @@
110 110 # The forms emitter and the description it renders. Runtime deps, not build:
111 111 # form markup depends on the value, the error and the options at the moment a
112 112 # modal opens, so it cannot be materialised the way the stylesheet is.
113 - makeover-webview = "0.18"
114 - makeover-layout = "0.9"
113 + makeover-webview = "0.19.0"
114 + makeover-layout = "0.10.0"
115 115 # Browser opening
116 116 open = { workspace = true }
117 117
@@ -136,16 +136,23 @@
136 136 /// this row uses now. The status keeps its tone through [`status_tone`], which is
137 137 /// the Rust half of the same table the JS reads.
138 138 ///
139 - /// # The second finding, still open
139 + /// # The second finding, closed against a member
140 140 ///
141 141 /// **A description carries text and this card carries markdown.**
142 142 /// `ProjectResponse::description_html` is `docengine::render_standard`, and the
143 143 /// card renders it as HTML. Nothing in the vocabulary names rich text, and it
144 144 /// should not be smuggled in as a string the renderer trusts — that is the one
145 145 /// door through which a description becomes a templating language. The raw
146 - /// description goes into `secondary` as text. A `Region::Bespoke` is the
147 - /// vocabulary's own answer for a place the app fills itself, and it is the
148 - /// shape this wants if it turns out to matter. Filed as `25822137`.
146 + /// description goes into `secondary` as text.
147 + ///
148 + /// Settled 2026-08-09, and this call site is the reason it settled the way it
149 + /// did. `secondary` is a row part, and the 2026-08-08 ruling refuses to let a
150 + /// row part hold an arbitrary node, so a `Region::Bespoke` cannot go here
151 + /// whatever else is decided. That left one live consumer — the task
152 + /// description, which is a standalone node — in one app, with Balanced
153 + /// Breakfast carrying no rich text at all. One consumer is not evidence, so
154 + /// `25822137` closed without a member and this stays text on purpose rather
155 + /// than pending. Re-file if a second standalone consumer appears.
149 156 fn row_for(project: &Project, current: bool, shared_only: bool, show_retired: bool) -> Row {
150 157 let mut row = Row::new(&project.name)
151 158 .token(Tag::badge(type_label(&project.project_type)))
@@ -239,7 +239,7 @@
239 239
240 240 /// The metadata section.
241 241 ///
242 - /// # The second finding, and it is one already open
242 + /// # The second finding, closed against a member
243 243 ///
244 244 /// **A description carries text and this task carries markdown.**
245 245 /// `TaskResponse::description_html` is `docengine::render_standard` and the JS
@@ -247,9 +247,16 @@
247 247 /// text and it must not be smuggled in as a string the renderer trusts. The raw
248 248 /// description goes in as text, correct and lossy.
249 249 ///
250 - /// Recorded here rather than filed again: this is makeover-layout `25822137`,
251 - /// found on the projects card. What this port adds is that it now has two
252 - /// consumers rather than one, which is the evidence that note asked for.
250 + /// This was makeover-layout `25822137`, found on the projects card, and this
251 + /// site was filed as its second consumer. Counting properly on 2026-08-09 took
252 + /// it back to one: the projects card wants rich text in a *row part*, which the
253 + /// row ruling refuses outright, so this standalone node was the only live
254 + /// consumer — in one app, Balanced Breakfast having none. One consumer is not
255 + /// evidence and the finding closed without a member.
256 + ///
257 + /// `Region::Bespoke` remains available here if this ever needs to render rich,
258 + /// and is the shape to reach for; it is not taken now because a bespoke region
259 + /// hands the space back to the JS, which is what describing the screen was for.
253 260 fn metadata(task: &Task) -> Vec<Node> {
254 261 let mut out = badges(task);
255 262 if !task.description.is_empty() {
@@ -299,24 +306,38 @@
299 306
300 307 /// The subtasks section.
301 308 ///
302 - /// # The fourth finding
309 + /// # The fourth finding, closed
303 310 ///
304 311 /// **A heading cannot carry a count, and a proportion cannot be drawn.** The JS
305 312 /// writes "Subtasks 3/7" with the count in its own small span, and a slim
306 - /// progress bar under it. `Node::section` takes one string, so the count is
307 - /// concatenated into the heading; nothing names a proportion, so the bar is
308 - /// gone and its number is in the heading too.
313 + /// progress bar under it. Two separate gaps landing in one place, and only one
314 + /// was worth a member.
309 315 ///
310 - /// Two separate gaps that land in one place, and only one is worth a member. A
311 - /// heading with a trailing count is a nice-to-have. A proportion is not: three
312 - /// screens draw one (subtasks, time tracking, the project dashboard), it has a
313 - /// tone in two of them, and "78%" as text is a different thing from a bar the
314 - /// eye reads without counting. Filed as makeover-layout `d0b58239`.
316 + /// The proportion was: nine sites across both apps drew a bar, and
317 + /// makeover-layout 0.10.0 added `Meter` for it. This section is the first
318 + /// consumer — the bar comes back, with the count still in the heading because
319 + /// that half is a nice-to-have and stays one. `d0b58239`.
320 + ///
321 + /// The count is deliberately not moved into the meter's label. The heading's
322 + /// count names the section and reads without the bar; the meter's label names
323 + /// what is being counted. Saying "3/7" twice would be the same fact in two
324 + /// places, which is what the concatenation was.
315 325 fn subtasks_section(task: &Task) -> Vec<Node> {
316 326 let done = task.subtasks_completed();
317 327 let total = task.subtask_count();
318 328
319 329 let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))];
330 + if total > 0 {
331 + out.push(Node::Meter {
332 + done: u32::try_from(done).unwrap_or(u32::MAX),
333 + total: u32::try_from(total).unwrap_or(u32::MAX),
334 + // Success, matching `tasks-render.js` and the four other subtask
335 + // rollups. Completion is the one proportion here that cannot mean
336 + // anything bad.
337 + tone: makeover_layout::Tone::Success,
338 + label: Some("subtasks".to_owned()),
339 + });
340 + }
320 341 if total > 0 {
321 342 out.push(Node::list(
322 343 task.subtasks
@@ -355,10 +376,16 @@
355 376
356 377 /// The time-tracking section.
357 378 ///
358 - /// The same proportion gap as the subtasks section, and here it also loses a
359 - /// tone: the JS colours the bar red past the estimate and green under it, so
360 - /// over-running is visible without reading. `is_over_estimate` is in the
361 - /// heading's text instead.
379 + /// The site that decided `Meter`'s shape. This bar is toned — the JS colours it
380 + /// red past the estimate and green under it — and it is the one place in either
381 + /// app where the numerator can exceed the denominator, which is why the member
382 + /// carries the pair and not the percentage `Task::time_progress` computes: that
383 + /// function clamps to 100 and the over-run survives only in the separate
384 + /// `is_over_estimate` flag beside it.
385 + ///
386 + /// So the heading keeps the readable summary and the meter carries the numbers
387 + /// unclamped. ", over" stays in the heading text: the meter says it to a
388 + /// renderer through `data-over`, and the heading says it to someone reading.
362 389 fn time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node> {
363 390 let tracked = format!("{}m tracked", task.actual_minutes);
364 391 let label = match task.estimated_minutes {
@@ -374,6 +401,20 @@
374 401 };
375 402
376 403 let mut out = vec![Node::section(format!("Time Tracking {label}"))];
404 + if let Some(estimate) = task.estimated_minutes.filter(|e| *e > 0) {
405 + out.push(Node::Meter {
406 + done: u32::try_from(task.actual_minutes).unwrap_or(u32::MAX),
407 + total: u32::try_from(estimate).unwrap_or(u32::MAX),
408 + // The tone `Meter` refuses to derive: the same fullness is success
409 + // on the subtask bar above and danger here.
410 + tone: if task.is_over_estimate() {
411 + makeover_layout::Tone::Danger
412 + } else {
413 + makeover_layout::Tone::Success
414 + },
415 + label: Some("minutes".to_owned()),
416 + });
417 + }
377 418 if !sessions.is_empty() {
378 419 out.push(Node::list(sessions.iter().map(session_row)));
379 420 }
@@ -51,10 +51,16 @@
51 51
52 52 /// One linked task.
53 53 ///
54 - /// The subtask bar the JS draws under the title is text in the meta slot, which
55 - /// is the proportion finding meeting its third and fourth call sites — a project
56 - /// dashboard draws one per task and one per milestone. makeover-layout
57 - /// `d0b58239`.
54 + /// The subtask bar the JS draws under the title is text in the meta slot, and it
55 + /// stays that way even though `d0b58239` closed: makeover-layout 0.10.0 named a
56 + /// proportion, but `Node::Meter` is a node and a `Row` holds no nodes. The task
57 + /// overview took the member at both its standalone sites; this one and
58 + /// `milestone_row` are the two that could not, on the 2026-08-08 ruling that a
59 + /// row part may not hold an arbitrary node.
60 + ///
61 + /// So the bar is closed for a proportion sitting in a region and open for one
62 + /// sitting in a row. Whether `RowPart` grows one is the follow-on, filed with
63 + /// the five row-shaped sites as its evidence: `da5666ae`.
58 64 fn task_row(task: &Task) -> Row {
59 65 let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str()));
60 66 if task.subtask_count() > 0 {
@@ -207,10 +207,11 @@
207 207 }
208 208
209 209 #[tokio::test]
210 - async fn a_proportion_is_text_because_nothing_names_a_bar() {
211 - // The fourth finding. Two progress bars on this screen, one of them toned,
212 - // and the vocabulary has neither the bar nor a count beside a heading. Both
213 - // end up concatenated into the heading string.
210 + async fn a_proportion_is_drawn_as_a_bar_and_not_only_counted() {
211 + // The fourth finding, closed by makeover-layout 0.10.0's `Meter`. This
212 + // asserted the loss until then: the bar and its tone were gone and only the
213 + // concatenated heading survived. Both are back, and the heading keeps its
214 + // count because that half was a separate gap and stays open.
214 215 let state = state().await;
215 216 let task = add(&state, "Has subtasks");
216 217 state
@@ -221,9 +222,50 @@
221 222 let page = html(get(&state, &format!("/tasks/{}", task.id)));
222 223
223 224 assert!(page.contains("Subtasks 0/1"));
224 - // The bar the JS draws under it, and its tone, are gone.
225 - assert!(!page.contains("progress-fill"));
226 - assert!(!page.contains("data-tone"));
225 + assert!(page.contains("progress-fill"));
226 + assert!(page.contains(r#"data-tone="success""#));
227 + // The ratio reaches a reader that cannot see the bar, which is the whole
228 + // reason the description carries both numbers instead of a percentage.
229 + assert!(page.contains(r#"aria-label="0 of 1 subtasks""#));
230 + }
231 +
232 + #[tokio::test]
233 + async fn an_over_run_estimate_is_toned_and_says_it_ran_over() {
234 + // The site that decided `Meter`'s shape. `Task::time_progress` would report
235 + // this as exactly 100%, because it clamps; the description carries 45 and
236 + // 30, so the bar is full AND the over-run survives in two places a
237 + // percentage could not have kept it.
238 + let state = state().await;
239 + let task = state
240 + .tasks
241 + .create(
242 + DESKTOP_USER_ID,
243 + NewTask::builder("Ran long")
244 + .title("Ran long")
245 + .priority(Priority::High)
246 + .estimated_minutes(30)
247 + .build(),
248 + )
249 + .unwrap();
250 + // `actual_minutes` is a cache the timer maintains, so there is no setter to
251 + // call; a test that wanted one through the front door would have to let
252 + // wall-clock time pass.
253 + state
254 + .db
255 + .conn()
256 + .unwrap()
257 + .execute(
258 + "UPDATE tasks SET actual_minutes = 45 WHERE id = ?",
259 + rusqlite::params![task.id.to_string()],
260 + )
261 + .unwrap();
262 +
263 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
264 +
265 + assert!(page.contains("width: 100%"));
266 + assert!(page.contains(r#"data-over="true""#));
267 + assert!(page.contains(r#"data-tone="danger""#));
268 + assert!(page.contains(r#"aria-label="45 of 30 minutes""#));
227 269 }
228 270
229 271 #[tokio::test]