max / goingson
6 files changed,
+154 insertions,
-45 deletions
| @@ -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] |