max / goingson
7 files changed,
+123 insertions,
-102 deletions
| @@ -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] |