Skip to main content

max / goingson

Take makeover 0.11: streak figures are a strip, row progress is a bar
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:52 UTC
Signed with PGP, not checked
Commit: c14756e9a0608568e0e466b09c98740192b20f8c
Parent: 4c6d07e
7 files changed, +123 insertions, -102 deletions
M Cargo.lock +16 -30
@@ -2174,8 +2174,8 @@
2174 2174 "makeover",
2175 2175 "makeover-build",
2176 2176 "makeover-geometry",
2177 - "makeover-layout 0.10.0",
2178 - "makeover-webview 0.19.0",
2177 + "makeover-layout 0.11.0",
2178 + "makeover-webview",
2179 2179 "notify",
2180 2180 "notify-debouncer-mini",
2181 2181 "open",
@@ -3285,13 +3285,13 @@
3285 3285
3286 3286 [[package]]
3287 3287 name = "makeover-build"
3288 - version = "0.10.0"
3288 + version = "0.12.0"
3289 3289 source = "registry+https://github.com/rust-lang/crates.io-index"
3290 - checksum = "3e766678f6a8b3481b21248b19bc47e1be60fdb6814d252434c4bcb827c01ecc"
3290 + checksum = "1a7b70edf04f3440bf01f2de7d7846cb7301c32e9d00276a33fd92548bbc34f9"
3291 3291 dependencies = [
3292 3292 "makeover",
3293 3293 "makeover-geometry",
3294 - "makeover-webview 0.18.0",
3294 + "makeover-webview",
3295 3295 ]
3296 3296
3297 3297 [[package]]
@@ -3313,14 +3313,10 @@
3313 3313 checksum = "586e191675d471f1b10a6d3620f8d9f796c74032bff3dd1efb0e578c290dc4ef"
3314 3314
3315 3315 [[package]]
3316 - name = "makeover-touch"
3317 - version = "0.5.0"
3316 + name = "makeover-layout"
3317 + version = "0.11.0"
3318 3318 source = "registry+https://github.com/rust-lang/crates.io-index"
3319 - checksum = "f3228b2d380252585ab2fadd6146229bb17ba51fab1db5d8e6001d7e0e40fbb4"
3320 - dependencies = [
3321 - "makeover-geometry",
3322 - "makeover-layout 0.9.0",
3323 - ]
3319 + checksum = "67da5deacb4c162878a5ed87839c1a08a7b1f473901f3a8ba1ae03b6ea3952a4"
3324 3320
3325 3321 [[package]]
3326 3322 name = "makeover-touch"
@@ -3345,24 +3341,13 @@
3345 3341
3346 3342 [[package]]
3347 3343 name = "makeover-webview"
3348 - version = "0.18.0"
3344 + version = "0.20.0"
3349 3345 source = "registry+https://github.com/rust-lang/crates.io-index"
3350 - checksum = "b0bec4e3395087aa3777bea4aa559d1b90d53e170ae2012c77095b29be16a1c2"
3346 + checksum = "7a7917e386d40de17c282f4a4354ca73172ae0a00953f0253d136270c6fb7763"
3351 3347 dependencies = [
3352 3348 "makeover-geometry",
3353 - "makeover-layout 0.9.0",
3354 - "makeover-touch 0.5.0",
3355 - ]
3356 -
3357 - [[package]]
3358 - name = "makeover-webview"
3359 - version = "0.19.0"
3360 - source = "registry+https://github.com/rust-lang/crates.io-index"
3361 - checksum = "e55462f42c8a1960ff7a58edb30717ffcea7b1df2f5a6f0bfb6d00230de7e234"
3362 - dependencies = [
3363 - "makeover-geometry",
3364 - "makeover-layout 0.10.0",
3365 - "makeover-touch 0.6.0",
3349 + "makeover-layout 0.11.0",
3350 + "makeover-touch",
3366 3351 ]
3367 3352
3368 3353 [[package]]
@@ -4558,7 +4543,7 @@
4558 4543 name = "quasi-router"
4559 4544 version = "0.1.0"
4560 4545 dependencies = [
4561 - "makeover-layout 0.10.0",
4546 + "makeover-layout 0.11.0",
4562 4547 ]
4563 4548
4564 4549 [[package]]
@@ -4575,8 +4560,9 @@
4575 4560 name = "quasi-webview"
4576 4561 version = "0.1.0"
4577 4562 dependencies = [
4578 - "makeover-layout 0.10.0",
4579 - "makeover-webview 0.19.0",
4563 + "docengine",
4564 + "makeover-layout 0.11.0",
4565 + "makeover-webview",
4580 4566 "quasi-http",
4581 4567 "quasi-router",
4582 4568 ]
@@ -16,12 +16,12 @@
16 16 [build-dependencies]
17 17 tauri-build = { workspace = true }
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css.
19 - makeover-build = "0.10"
19 + makeover-build = "0.12"
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.19.0"
24 - makeover-layout = "0.10.0"
23 + makeover-webview = "0.20.0"
24 + makeover-layout = "0.11.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.19.0"
114 - makeover-layout = "0.10.0"
113 + makeover-webview = "0.20.0"
114 + makeover-layout = "0.11.0"
115 115 # Browser opening
116 116 open = { workspace = true }
117 117
@@ -193,6 +193,13 @@
193 193 placeholder: spec.placeholder.as_deref(),
194 194 options: &choices,
195 195 required: spec.required,
196 + // makeover-layout 0.11.0's constraints. This form spec carries none
197 + // of them yet: the runtime form model is the JS one, and adding
198 + // them here without the spec growing fields would be inventing
199 + // limits the app never stated.
200 + max_length: None,
201 + min: None,
202 + max: None,
196 203 extended: spec.extended,
197 204 };
198 205
@@ -36,7 +36,7 @@
36 36
37 37 use chrono::{DateTime, Local, Utc};
38 38 use goingson_core::{Annotation, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession};
39 - use quasi_router::screen::{Act, Field, Row, Tag};
39 + use quasi_router::screen::{Act, Field, Figure, Meter, Row, Tag};
40 40 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
41 41
42 42 use crate::commands::{StreakInfo, compute_streak};
@@ -128,40 +128,45 @@
128 128 Ok(Some(compute_streak(&chain)))
129 129 }
130 130
131 - /// One streak figure.
131 + /// The four streak figures, as one strip.
132 132 ///
133 - /// # The first finding
133 + /// # The first finding, closed
134 134 ///
135 - /// **A figure with a label is not a row, and there is nothing else to make it
136 - /// out of.** The four stats are the shape every dashboard has: a large value
137 - /// over a small caption, laid out as a strip. `Row` is a list item — primary
138 - /// text with trailing facts — so this reads them backwards, putting the caption
139 - /// first because that is what a row's primary slot means. Four of them then
140 - /// render as a list where the JS renders a strip of tiles.
135 + /// **A figure with a label is not a row, and there was nothing else to make it
136 + /// out of.** These are the shape every dashboard has — a large value over a
137 + /// small caption, laid out as a strip — and the port made each one a `Row` with
138 + /// the caption as `primary` and the figure as `meta`, which reads backwards: a
139 + /// row's primary slot means the thing itself, and here the thing is the number.
140 + /// Four of them then rendered as a list where the JS renders tiles.
141 141 ///
142 - /// Everything survives and the arrangement does not, which is the same shape as
143 - /// the token finding that `RowPart::Tokens` closed. Filed as makeover-layout
144 - /// `93c6a174`.
145 - fn stat(label: &str, value: impl Into<String>) -> Row {
146 - Row::new(label).meta(value)
142 + /// `93c6a174`, closed 2026-08-09 as `makeover_layout::Figure` plus
143 + /// `Node::Stats`. The set is the node rather than each figure, because four
144 + /// tiles in a strip and four down a column are different things and a renderer
145 + /// handed one at a time cannot tell which it is looking at.
146 + ///
147 + /// None of these four answers a click. `Node::Stats` carries an optional action
148 + /// per figure for the one goingson site that does — sync's "Not Applied: 3" —
149 + /// and this section does not use it.
150 + fn habit_figures(streak: &StreakInfo) -> Node {
151 + Node::stats([
152 + Figure::new(format!("{}d", streak.current_streak), "Current Streak"),
153 + Figure::new(format!("{}d", streak.best_streak), "Best Streak"),
154 + Figure::new(
155 + format!("{}%", streak.completion_rate_30d.round() as i64),
156 + "Completion Rate",
157 + ),
158 + Figure::new(
159 + format!("{}/{}", streak.total_completed, streak.total_instances),
160 + "Total Completed",
161 + ),
162 + ])
147 163 }
148 164
149 165 /// The completion-history section, for a recurring task.
150 166 fn habit_section(streak: &StreakInfo, task_id: TaskId) -> Vec<Node> {
151 167 vec![
152 168 Node::section("Completion History"),
153 - Node::list([
154 - stat("Current Streak", format!("{}d", streak.current_streak)),
155 - stat("Best Streak", format!("{}d", streak.best_streak)),
156 - stat(
157 - "Completion Rate",
158 - format!("{}%", streak.completion_rate_30d.round() as i64),
159 - ),
160 - stat(
161 - "Total Completed",
162 - format!("{}/{}", streak.total_completed, streak.total_instances),
163 - ),
164 - ]),
169 + habit_figures(streak),
165 170 // The heatmap. A month grid of completion counts is exactly what
166 171 // `Region::Bespoke` was named for: the description says a thing called
167 172 // `task-heatmap` goes here and says nothing else, and
@@ -334,15 +339,17 @@
334 339
335 340 let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))];
336 341 if total > 0 {
337 - out.push(Node::Meter {
338 - done: u32::try_from(done).unwrap_or(u32::MAX),
339 - total: u32::try_from(total).unwrap_or(u32::MAX),
342 + out.push(Node::Meter(
343 + Meter::new(
344 + u32::try_from(done).unwrap_or(u32::MAX),
345 + u32::try_from(total).unwrap_or(u32::MAX),
346 + )
340 347 // Success, matching `tasks-render.js` and the four other subtask
341 348 // rollups. Completion is the one proportion here that cannot mean
342 349 // anything bad.
343 - tone: makeover_layout::Tone::Success,
344 - label: Some("subtasks".to_owned()),
345 - });
350 + .tone(makeover_layout::Tone::Success)
351 + .label("subtasks"),
352 + ));
346 353 }
347 354 if total > 0 {
348 355 out.push(Node::list(
@@ -408,18 +415,20 @@
408 415
409 416 let mut out = vec![Node::section(format!("Time Tracking {label}"))];
410 417 if let Some(estimate) = task.estimated_minutes.filter(|e| *e > 0) {
411 - out.push(Node::Meter {
412 - done: u32::try_from(task.actual_minutes).unwrap_or(u32::MAX),
413 - total: u32::try_from(estimate).unwrap_or(u32::MAX),
418 + out.push(Node::Meter(
419 + Meter::new(
420 + u32::try_from(task.actual_minutes).unwrap_or(u32::MAX),
421 + u32::try_from(estimate).unwrap_or(u32::MAX),
422 + )
414 423 // The tone `Meter` refuses to derive: the same fullness is success
415 424 // on the subtask bar above and danger here.
416 - tone: if task.is_over_estimate() {
425 + .tone(if task.is_over_estimate() {
417 426 makeover_layout::Tone::Danger
418 427 } else {
419 428 makeover_layout::Tone::Success
420 - },
421 - label: Some("minutes".to_owned()),
422 - });
429 + })
430 + .label("minutes"),
431 + ));
423 432 }
424 433 if !sessions.is_empty() {
425 434 out.push(Node::list(sessions.iter().map(session_row)));
@@ -40,7 +40,7 @@
40 40 use goingson_core::{
41 41 Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
42 42 };
43 - use quasi_router::screen::{Act, Row, Tag};
43 + use quasi_router::screen::{Act, Meter, Row, Tag};
44 44 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
45 45
46 46 use super::{filtered_by, project_id, status_tone, type_label};
@@ -51,24 +51,26 @@
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, 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.
54 + /// The subtask bar the JS draws under the title was text in the meta slot until
55 + /// 2026-08-09, and the reason is worth keeping: `d0b58239` closed at
56 + /// makeover-layout 0.10.0 and could not reach here, because `Node::Meter` is a
57 + /// node and a `Row` holds no nodes. The task overview took the member at both
58 + /// its standalone sites; this one and `milestone_row` were two of the five that
59 + /// could not.
60 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`.
61 + /// `da5666ae` closed that at 0.11.0 with `RowPart::Proportion`, which is
62 + /// `RowPart::Tokens`' answer applied again: the row carries the description of a
63 + /// bar rather than a node, so the 2026-08-08 ruling stands untouched.
64 64 fn task_row(task: &Task) -> Row {
65 65 let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str()));
66 66 if task.subtask_count() > 0 {
67 - row = row.meta(format!(
68 - "{}/{} subtasks",
69 - task.subtasks_completed(),
70 - task.subtask_count()
71 - ));
67 + row = row.meter(
68 + Meter::new(
69 + u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX),
70 + u32::try_from(task.subtask_count()).unwrap_or(u32::MAX),
71 + )
72 + .label("subtasks"),
73 + );
72 74 }
73 75 if task.due.is_some() {
74 76 row = row.token(Tag::badge(task.due_formatted()));
@@ -207,7 +209,16 @@
207 209 of: usize,
208 210 ) -> Row {
209 211 let (done, total) = milestone_progress(tasks, milestone);
210 - let mut row = Row::new(&milestone.name).meta(format!("{done}/{total}"));
212 + // A bar rather than "3/7" in the meta slot, as of `da5666ae`. The ratio is
213 + // still readable: `meter_html` writes both numbers into the accessible name,
214 + // which is what the concatenated text was for.
215 + let mut row = Row::new(&milestone.name).meter(
216 + Meter::new(
217 + u32::try_from(done).unwrap_or(u32::MAX),
218 + u32::try_from(total).unwrap_or(u32::MAX),
219 + )
220 + .label("tasks"),
221 + );
211 222
212 223 if let Some(date) = milestone.target_date {
213 224 row = row.token(Tag::badge(date.format("%Y-%m-%d").to_string()));
@@ -149,10 +149,11 @@
149 149 }
150 150
151 151 #[tokio::test]
152 - async fn a_stat_reads_backwards_because_nothing_names_a_figure_with_a_caption() {
153 - // The first finding, asserted. `Row` is a list item, so the caption lands in
154 - // the primary slot and the figure in the trailing one -- the opposite of the
155 - // tile the JS draws. When this assertion has to change, the finding closed.
152 + async fn a_stat_is_a_figure_with_a_caption_and_not_a_row_read_backwards() {
153 + // The first finding, closed. It asserted the workaround until 2026-08-09:
154 + // `Row` is a list item, so the caption landed in the primary slot and the
155 + // figure in the trailing one, the opposite of the tile the JS draws.
156 + // `93c6a174`.
156 157 let state = state().await;
157 158 let task = state
158 159 .tasks
@@ -168,9 +169,12 @@
168 169 let page = html(get(&state, &format!("/tasks/{}", task.id)));
169 170
170 171 assert!(page.contains("Current Streak"));
171 - assert!(page.contains("row-primary"));
172 - // The figure is trailing text, which is what a row's meta slot is.
173 - assert!(page.contains("row-meta"));
172 + // A strip of figures, not a list of rows read backwards.
173 + assert!(page.contains(r#"<div class="figures">"#));
174 + assert!(page.contains(r#"aria-label="Current Streak: 0d""#));
175 + // Four of them, which is the arrangement a renderer could not have inferred
176 + // from four separate nodes.
177 + assert_eq!(page.matches(r#"class="figure""#).count(), 4);
174 178 }
175 179
176 180 #[tokio::test]
@@ -209,9 +209,10 @@
209 209 }
210 210
211 211 #[tokio::test]
212 - async fn a_milestone_carries_its_progress_as_text_because_nothing_names_a_bar() {
213 - // The proportion finding's third and fourth call sites. makeover-layout
214 - // `d0b58239`.
212 + async fn a_milestone_draws_its_progress_as_a_bar_in_the_row() {
213 + // The proportion finding's third and fourth call sites. `d0b58239` named a
214 + // bar at 0.10.0 and could not reach a row; `da5666ae` closed that at 0.11.0
215 + // with `RowPart::Proportion`. This asserted the text form until then.
215 216 let state = state().await;
216 217 let project = project(&state);
217 218 let target = milestone(&state, project, "Phase one");
@@ -231,8 +232,11 @@
231 232
232 233 let page = dashboard(&state, project);
233 234 assert!(page.contains("Phase one"));
234 - assert!(page.contains("1/1"));
235 - assert!(!page.contains("progress-fill"));
235 + assert!(page.contains("row-proportion"));
236 + assert!(page.contains("progress-fill"));
237 + // The ratio is still readable. It moved from the meta slot into the bar's
238 + // accessible name, which is what the concatenated text was for.
239 + assert!(page.contains(r#"aria-label="1 of 1 tasks""#));
236 240 }
237 241
238 242 #[tokio::test]