max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
1 file changed,
+241 insertions,
-223 deletions
| @@ -36,8 +36,9 @@ | |||
| 36 | 36 | Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId, | |
| 37 | 37 | TaskStatus, TimeSession, UpdateTask, | |
| 38 | 38 | }; | |
| 39 | - | use quasi_router::screen::{Act, Choice, Field, Figure, Meter, Row, Tag}; | |
| 40 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 39 | + | use quasi_declare::declare; | |
| 40 | + | use quasi_router::screen::{Choice, Figure, Tag}; | |
| 41 | + | use quasi_router::{Action, RegionKind, Response, RouteError, Router}; | |
| 41 | 42 | ||
| 42 | 43 | use super::parse_optional_id; | |
| 43 | 44 | use crate::commands::{StreakInfo, compute_streak}; | |
| @@ -129,319 +130,384 @@ | |||
| 129 | 130 | Ok(Some(compute_streak(&chain))) | |
| 130 | 131 | } | |
| 131 | 132 | ||
| 132 | - | /// The four streak figures, as one strip. | |
| 133 | - | /// | |
| 134 | - | /// A large value over a small caption, laid out as a strip: | |
| 135 | - | /// `makeover_layout::Figure` plus `Node::Stats`. The set is the node rather | |
| 136 | - | /// than each figure, because four tiles in a strip and four down a column are | |
| 137 | - | /// different things and a renderer handed one at a time cannot tell which it is | |
| 138 | - | /// looking at. | |
| 139 | - | /// | |
| 140 | - | /// None of these four answers a click. `Node::Stats` carries an optional action | |
| 141 | - | /// per figure for the one goingson site that does — sync's "Not Applied: 3" — | |
| 142 | - | /// and this section does not use it. | |
| 143 | - | fn habit_figures(streak: &StreakInfo) -> Node { | |
| 144 | - | Node::stats([ | |
| 145 | - | Figure::new(format!("{}d", streak.current_streak), "Current Streak"), | |
| 146 | - | Figure::new(format!("{}d", streak.best_streak), "Best Streak"), | |
| 147 | - | Figure::new( | |
| 148 | - | format!("{}%", streak.completion_rate_30d.round() as i64), | |
| 149 | - | "Completion Rate", | |
| 150 | - | ), | |
| 151 | - | Figure::new( | |
| 152 | - | format!("{}/{}", streak.total_completed, streak.total_instances), | |
| 153 | - | "Total Completed", | |
| 154 | - | ), | |
| 155 | - | ]) | |
| 133 | + | declare! { | |
| 134 | + | /// The four streak figures, as one strip. | |
| 135 | + | /// | |
| 136 | + | /// A large value over a small caption, laid out as a strip: `Figure` plus | |
| 137 | + | /// `Node::Stats`. The set is the node rather than each figure, because four | |
| 138 | + | /// tiles in a strip and four down a column are different things and a | |
| 139 | + | /// renderer handed one at a time cannot tell which it is looking at. | |
| 140 | + | /// | |
| 141 | + | /// None of these four answers a click. `Node::Stats` carries an optional | |
| 142 | + | /// action per figure for the one goingson site that does -- sync's "Not | |
| 143 | + | /// Applied: 3" -- and this section does not use it. | |
| 144 | + | shape habit_figures(streak: &StreakInfo) -> Node; | |
| 145 | + | ||
| 146 | + | stats [] { | |
| 147 | + | figure Figure::new("{streak.current_streak}d", "Current Streak"); | |
| 148 | + | figure Figure::new("{streak.best_streak}d", "Best Streak"); | |
| 149 | + | figure Figure::new("{completion_rate(streak)}%", "Completion Rate"); | |
| 150 | + | figure Figure::new( | |
| 151 | + | "{streak.total_completed}/{streak.total_instances}", | |
| 152 | + | "Total Completed" | |
| 153 | + | ); | |
| 154 | + | } | |
| 156 | 155 | } | |
| 157 | 156 | ||
| 158 | - | /// The completion-history section, for a recurring task. | |
| 159 | - | fn habit_section(streak: &StreakInfo, task_id: TaskId) -> Vec<Node> { | |
| 160 | - | vec![ | |
| 161 | - | Node::section("Completion History"), | |
| 162 | - | habit_figures(streak), | |
| 163 | - | // The heatmap. A month grid of completion counts is exactly what | |
| 164 | - | // `Region::Handover` was named for: the description says a thing called | |
| 165 | - | // `task-heatmap` goes here and says nothing else, and | |
| 166 | - | // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a | |
| 167 | - | // gap — a description able to produce a calendar grid is a widget | |
| 168 | - | // library with a description's name on it. | |
| 169 | - | // | |
| 170 | - | // The id carries the task so the filling code knows which chain to | |
| 171 | - | // render without asking the screen. | |
| 172 | - | Node::Region(Slot::ceded( | |
| 173 | - | format!("task-heatmap-{task_id}"), | |
| 174 | - | "task-heatmap", | |
| 175 | - | )), | |
| 176 | - | ] | |
| 157 | + | /// The thirty-day rate, rounded the way the strip shows it. | |
| 158 | + | fn completion_rate(streak: &StreakInfo) -> i64 { | |
| 159 | + | streak.completion_rate_30d.round() as i64 | |
| 177 | 160 | } | |
| 178 | 161 | ||
| 179 | - | /// The badges across the top: status, priority, and whatever else is true. | |
| 180 | - | /// | |
| 181 | - | /// Tokens rather than text, so a status keeps its tone. The three conditional | |
| 182 | - | /// ones come in the order focus, overdue, snoozed. | |
| 183 | - | fn badges(task: &Task) -> Vec<Node> { | |
| 184 | - | let mut out = vec![ | |
| 185 | - | Node::Token(Tag::badge(task.status.as_str()).tone(status_tone(&task.status))), | |
| 186 | - | Node::Token(Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority))), | |
| 187 | - | ]; | |
| 188 | - | if task.is_focus { | |
| 189 | - | out.push(Node::Token( | |
| 190 | - | Tag::badge("Focus").tone(makeover_layout::Tone::Info), | |
| 191 | - | )); | |
| 192 | - | } | |
| 193 | - | if task.is_overdue() { | |
| 194 | - | out.push(Node::Token( | |
| 195 | - | Tag::badge("Overdue").tone(makeover_layout::Tone::Danger), | |
| 196 | - | )); | |
| 197 | - | } | |
| 198 | - | if task.is_snoozed() { | |
| 199 | - | out.push(Node::Token( | |
| 200 | - | Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning), | |
| 201 | - | )); | |
| 202 | - | } | |
| 203 | - | out | |
| 162 | + | declare! { | |
| 163 | + | /// The completion-history section, for a recurring task. | |
| 164 | + | shape habit_section(streak: &StreakInfo, task: TaskId) -> Vec<Node>; | |
| 165 | + | ||
| 166 | + | section "Completion History"; | |
| 167 | + | include habit_figures(streak); | |
| 168 | + | ||
| 169 | + | // The heatmap. A month grid of completion counts is exactly what | |
| 170 | + | // `RegionKind::Ceded` was named for: the description says a thing called | |
| 171 | + | // `task-heatmap` goes here and says nothing else, and | |
| 172 | + | // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a gap | |
| 173 | + | // -- a description able to produce a calendar grid is a widget library with | |
| 174 | + | // a description's name on it. | |
| 175 | + | // | |
| 176 | + | // The id carries the task so the filling code knows which chain to render | |
| 177 | + | // without asking the screen. | |
| 178 | + | region "task-heatmap-{task}" as RegionKind::ceded("task-heatmap") {} | |
| 204 | 179 | } | |
| 205 | 180 | ||
| 206 | - | /// The labelled facts under the badges. | |
| 207 | - | /// | |
| 208 | - | /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the label | |
| 209 | - | /// and whose meta is the value. The JS bolds the label inside a sentence; a row | |
| 210 | - | /// is the nearest thing the vocabulary has to a definition list, and unlike the | |
| 211 | - | /// stats above the label really is the primary here. | |
| 212 | - | fn details(task: &Task) -> Vec<Row> { | |
| 213 | - | let mut rows = Vec::new(); | |
| 214 | - | if let Some(project) = &task.project_name { | |
| 215 | - | rows.push(Row::new("Project").meta(project)); | |
| 181 | + | declare! { | |
| 182 | + | /// The badges across the top: status, priority, and whatever else is true. | |
| 183 | + | /// | |
| 184 | + | /// Tokens rather than text, so a status keeps its tone. The three | |
| 185 | + | /// conditional ones come in the order focus, overdue, snoozed. | |
| 186 | + | shape badges(task: &Task) -> Vec<Node>; | |
| 187 | + | ||
| 188 | + | badge task.status.as_str() { | |
| 189 | + | tone status_tone(&task.status); | |
| 216 | 190 | } | |
| 217 | - | if task.due.is_some() { | |
| 218 | - | rows.push(Row::new("Due").meta(task.due_formatted())); | |
| 191 | + | ||
| 192 | + | badge task.priority.as_str() { | |
| 193 | + | tone priority_tone(&task.priority); | |
| 219 | 194 | } | |
| 220 | - | if task.has_recurrence() { | |
| 221 | - | rows.push(Row::new("Recurrence").meta(task.recurrence.as_str())); | |
| 195 | + | ||
| 196 | + | badge "Focus" when task.is_focus { | |
| 197 | + | tone Info; | |
| 222 | 198 | } | |
| 223 | - | if let Some(contact) = &task.contact_name { | |
| 224 | - | rows.push(Row::new("Contact").meta(contact)); | |
| 199 | + | ||
| 200 | + | badge "Overdue" when task.is_overdue() { | |
| 201 | + | tone Danger; | |
| 225 | 202 | } | |
| 226 | - | if !task.tags.is_empty() { | |
| 203 | + | ||
| 204 | + | badge "Snoozed" when task.is_snoozed() { | |
| 205 | + | tone Warning; | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | declare! { | |
| 210 | + | /// The metadata section. | |
| 211 | + | /// | |
| 212 | + | /// A task's description is markdown, and it goes in as `rich`, which | |
| 213 | + | /// carries the markdown **source** rather than markup. Every renderer | |
| 214 | + | /// renders that source its own way, and nothing in a description is ever | |
| 215 | + | /// markup, so `Node::Text`'s escaping guarantee is untouched. | |
| 216 | + | /// | |
| 217 | + | /// quasi-webview renders it through docengine's strict preset, so raw HTML | |
| 218 | + | /// inside a description reads as text. A shared renderer taking what a user | |
| 219 | + | /// typed should be the safer of the two. | |
| 220 | + | /// | |
| 221 | + | /// # The labelled facts under the badges | |
| 222 | + | /// | |
| 223 | + | /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the | |
| 224 | + | /// label and whose meta is the value. The JS bolds the label inside a | |
| 225 | + | /// sentence; a row is the nearest thing the vocabulary has to a definition | |
| 226 | + | /// list, and unlike the stats above the label really is the primary here. | |
| 227 | + | shape metadata(task: &Task) -> Vec<Node>; | |
| 228 | + | ||
| 229 | + | extend badges(task); | |
| 230 | + | ||
| 231 | + | rich &task.description unless task.description.is_empty(); | |
| 232 | + | ||
| 233 | + | list { | |
| 234 | + | for project in task.project_name.iter() { | |
| 235 | + | row "Project" { | |
| 236 | + | meta project; | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | row "Due" when task.due.is_some() { | |
| 241 | + | meta task.due_formatted(); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | row "Recurrence" when task.has_recurrence() { | |
| 245 | + | meta task.recurrence.as_str(); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | for contact in task.contact_name.iter() { | |
| 249 | + | row "Contact" { | |
| 250 | + | meta contact; | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 227 | 254 | // Tokens, because a tag is a badge in the JS and `RowPart::Tokens` | |
| 228 | 255 | // exists now to keep it one. | |
| 229 | - | let mut row = Row::new("Tags"); | |
| 230 | - | for tag in &task.tags { | |
| 231 | - | row = row.token(Tag::badge(tag)); | |
| 256 | + | row "Tags" unless task.tags.is_empty() { | |
| 257 | + | for tag in task.tags.iter() { | |
| 258 | + | token Tag::badge(tag); | |
| 259 | + | } | |
| 232 | 260 | } | |
| 233 | - | rows.push(row); | |
| 234 | - | } | |
| 235 | - | rows | |
| 261 | + | } unless no_details(task); | |
| 236 | 262 | } | |
| 237 | 263 | ||
| 238 | - | /// The metadata section. | |
| 239 | - | /// | |
| 240 | - | /// A task's description is markdown, and it goes in as [`Node::Rich`], which | |
| 241 | - | /// carries the markdown **source** rather than markup. Every renderer renders | |
| 242 | - | /// that source its own way, and nothing in a description is ever markup, so | |
| 243 | - | /// `Node::Text`'s escaping guarantee is untouched. | |
| 244 | - | /// | |
| 245 | - | /// quasi-webview renders it through docengine's strict preset, so raw HTML | |
| 246 | - | /// inside a description reads as text. A shared renderer taking what a user | |
| 247 | - | /// typed should be the safer of the two. | |
| 248 | - | fn metadata(task: &Task) -> Vec<Node> { | |
| 249 | - | let mut out = badges(task); | |
| 250 | - | if !task.description.is_empty() { | |
| 251 | - | out.push(Node::rich(&task.description)); | |
| 252 | - | } | |
| 253 | - | let rows = details(task); | |
| 254 | - | if !rows.is_empty() { | |
| 255 | - | out.push(Node::list(rows)); | |
| 256 | - | } | |
| 257 | - | out | |
| 264 | + | /// Whether the task has any labelled fact worth a row. | |
| 265 | + | fn no_details(task: &Task) -> bool { | |
| 266 | + | task.project_name.is_none() | |
| 267 | + | && task.due.is_none() | |
| 268 | + | && !task.has_recurrence() | |
| 269 | + | && task.contact_name.is_none() | |
| 270 | + | && task.tags.is_empty() | |
| 258 | 271 | } | |
| 259 | 272 | ||
| 260 | - | /// One subtask. | |
| 261 | - | /// | |
| 262 | - | /// # The tick is the write | |
| 263 | - | /// | |
| 264 | - | /// **A tick that means something has no route.** `Row::selected` said whether a | |
| 265 | - | /// row is ticked and whether it can be, and nothing said what ticking it | |
| 266 | - | /// *calls*. That was right for the case it was added for — goingson's | |
| 267 | - | /// bulk-selection checkboxes are client state feeding a later bulk action — and | |
| 268 | - | /// a subtask is the other case: the tick is the write. | |
| 269 | - | /// | |
| 270 | - | /// `Row::toggle` lives on quasi-router rather than makeover-layout: the | |
| 271 | - | /// vocabulary has no notion of an action, so "what this calls" is not a thing | |
| 272 | - | /// it can say. A standalone control uses `Field::writes` instead. | |
| 273 | - | /// | |
| 274 | - | /// A linked subtask keeps the button: its state follows the task it links to, so | |
| 275 | - | /// it is not tickable at all, and `Act::disabled` is what says that. Describing | |
| 276 | - | /// it as a tick that refuses to move would be the same defect the other way | |
| 277 | - | /// round. | |
| 278 | - | fn subtask_row(task: TaskId, subtask: &Subtask) -> Row { | |
| 279 | - | let action = Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id)); | |
| 273 | + | declare! { | |
| 274 | + | /// One subtask. | |
| 275 | + | /// | |
| 276 | + | /// # The tick is the write | |
| 277 | + | /// | |
| 278 | + | /// **A tick that means something has no route.** `Row::selected` said | |
| 279 | + | /// whether a row is ticked and whether it can be, and nothing said what | |
| 280 | + | /// ticking it *calls*. That was right for the case it was added for -- | |
| 281 | + | /// goingson's bulk-selection checkboxes are client state feeding a later | |
| 282 | + | /// bulk action -- and a subtask is the other case: the tick is the write. | |
| 283 | + | /// | |
| 284 | + | /// `Row::toggle` lives on quasi-router rather than makeover-layout: the | |
| 285 | + | /// vocabulary has no notion of an action, so "what this calls" is not a | |
| 286 | + | /// thing it can say. A standalone control uses `Field::writes` instead. | |
| 287 | + | /// | |
| 288 | + | /// A linked subtask keeps the button: its state follows the task it links | |
| 289 | + | /// to, so it is not tickable at all, and a disabled act is what says that. | |
| 290 | + | /// Describing it as a tick that refuses to move would be the same defect | |
| 291 | + | /// the other way round. | |
| 292 | + | shape subtask_row(task: TaskId, subtask: &Subtask) -> Row; | |
| 280 | 293 | ||
| 281 | - | if subtask.linked_task_id.is_some() { | |
| 282 | - | return Row::new(&subtask.text) | |
| 283 | - | .selectable(subtask.is_completed) | |
| 284 | - | .token(Tag::badge("Linked")) | |
| 285 | - | .act(Act::new(if subtask.is_completed { "Undo" } else { "Done" }, action).disabled()); | |
| 294 | + | row &subtask.text { | |
| 295 | + | toggling subtask.is_completed | |
| 296 | + | Action::post("/tasks/{task}/subtasks/{subtask.id}/toggle") | |
| 297 | + | unless is_linked(subtask); | |
| 298 | + | ||
| 299 | + | selectable subtask.is_completed when is_linked(subtask); | |
| 300 | + | token Tag::badge("Linked") when is_linked(subtask); | |
| 301 | + | ||
| 302 | + | act undo_or_done(subtask) | |
| 303 | + | to post "/tasks/{task}/subtasks/{subtask.id}/toggle" | |
| 304 | + | when is_linked(subtask) { | |
| 305 | + | disabled; | |
| 306 | + | } | |
| 286 | 307 | } | |
| 287 | - | ||
| 288 | - | Row::new(&subtask.text).toggling(subtask.is_completed, action) | |
| 289 | 308 | } | |
| 290 | 309 | ||
| 291 | - | /// The subtasks section. | |
| 292 | - | /// | |
| 293 | - | /// # A heading cannot carry a count, and a proportion is a `Meter` | |
| 294 | - | /// | |
| 295 | - | /// The count rides in the heading text; the proportion is | |
| 296 | - | /// `makeover_layout::Meter`. This section is the first | |
| 297 | - | /// consumer — the bar comes back, with the count still in the heading because | |
| 298 | - | /// that half is a nice-to-have and stays one. `d0b58239`. | |
| 299 | - | /// | |
| 300 | - | /// The count is deliberately not moved into the meter's label. The heading's | |
| 301 | - | /// count names the section and reads without the bar; the meter's label names | |
| 302 | - | /// what is being counted. Saying "3/7" twice would be the same fact in two | |
| 303 | - | /// places, which is what the concatenation was. | |
| 304 | - | fn subtasks_section(task: &Task) -> Vec<Node> { | |
| 305 | - | let done = task.subtasks_completed(); | |
| 306 | - | let total = task.subtask_count(); | |
| 307 | - | ||
| 308 | - | let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))]; | |
| 309 | - | if total > 0 { | |
| 310 | - | out.push(Node::Meter( | |
| 311 | - | Meter::new( | |
| 312 | - | u32::try_from(done).unwrap_or(u32::MAX), | |
| 313 | - | u32::try_from(total).unwrap_or(u32::MAX), | |
| 314 | - | ) | |
| 315 | - | // Success, matching `tasks-render.js` and the four other subtask | |
| 316 | - | // rollups. Completion is the one proportion here that cannot mean | |
| 317 | - | // anything bad. | |
| 318 | - | .tone(makeover_layout::Tone::Success) | |
| 319 | - | .label("subtasks"), | |
| 320 | - | )); | |
| 321 | - | } | |
| 322 | - | if total > 0 { | |
| 323 | - | out.push(Node::list( | |
| 324 | - | task.subtasks | |
| 325 | - | .iter() | |
| 326 | - | .map(|subtask| subtask_row(task.id, subtask)), | |
| 327 | - | )); | |
| 328 | - | } | |
| 329 | - | out.push(Node::Form { | |
| 330 | - | action: Action::post(format!("/tasks/{}/subtasks", task.id)), | |
| 331 | - | submit: "Add".to_owned(), | |
| 332 | - | fields: vec![{ | |
| 333 | - | let mut field = | |
| 334 | - | Field::new(makeover_layout::FieldKind::Text, "text", "Subtask").required(); | |
| 335 | - | field.placeholder = Some("Add subtask...".to_owned()); | |
| 336 | - | field | |
| 337 | - | }], | |
| 338 | - | }); | |
| 339 | - | out | |
| 310 | + | /// Whether the subtask's state follows a task it links to. | |
| 311 | + | fn is_linked(subtask: &Subtask) -> bool { | |
| 312 | + | subtask.linked_task_id.is_some() | |
| 340 | 313 | } | |
| 341 | 314 | ||
| 342 | - | /// One tracked session. | |
| 343 | - | fn session_row(session: &TimeSession) -> Row { | |
| 344 | - | let started = session.started_at.with_timezone(&Local); | |
| 345 | - | Row::new(format!( | |
| 315 | + | /// What the linked subtask's refused button reads. | |
| 316 | + | fn undo_or_done(subtask: &Subtask) -> &'static str { | |
| 317 | + | if subtask.is_completed { "Undo" } else { "Done" } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | declare! { | |
| 321 | + | /// The subtasks section. | |
| 322 | + | /// | |
| 323 | + | /// # A heading cannot carry a count, and a proportion is a `Meter` | |
| 324 | + | /// | |
| 325 | + | /// The count rides in the heading text; the proportion is a `proportion` | |
| 326 | + | /// member. This section is the first consumer -- the bar comes back, with | |
| 327 | + | /// the count still in the heading because that half is a nice-to-have and | |
| 328 | + | /// stays one. `d0b58239`. | |
| 329 | + | /// | |
| 330 | + | /// The count is deliberately not moved into the meter's label. The | |
| 331 | + | /// heading's count names the section and reads without the bar; the meter's | |
| 332 | + | /// label names what is being counted. Saying "3/7" twice would be the same | |
| 333 | + | /// fact in two places, which is what the concatenation was. | |
| 334 | + | shape subtasks_section(task: &Task) -> Vec<Node>; | |
| 335 | + | ||
| 336 | + | section "Subtasks {task.subtasks_completed()}/{task.subtask_count()}"; | |
| 337 | + | ||
| 338 | + | // Success, matching `tasks-render.js` and the four other subtask rollups. | |
| 339 | + | // Completion is the one proportion here that cannot mean anything bad. | |
| 340 | + | proportion counted(task.subtasks_completed()) counted(task.subtask_count()) | |
| 341 | + | unless task.subtasks.is_empty() { | |
| 342 | + | tone Success; | |
| 343 | + | label "subtasks"; | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | list { | |
| 347 | + | for subtask in task.subtasks.iter() { | |
| 348 | + | include subtask_row(task.id, subtask); | |
| 349 | + | } | |
| 350 | + | } unless task.subtasks.is_empty(); | |
| 351 | + | ||
| 352 | + | form post "/tasks/{task.id}/subtasks" { | |
| 353 | + | submit "Add"; | |
| 354 | + | ||
| 355 | + | field Text "text" "Subtask" { | |
| 356 | + | required; | |
| 357 | + | placeholder "Add subtask..."; | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// A count as a meter reads one. | |
| 363 | + | fn counted(n: usize) -> u32 { | |
| 364 | + | u32::try_from(n).unwrap_or(u32::MAX) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | declare! { | |
| 368 | + | /// One tracked session. | |
| 369 | + | shape session_row(session: &TimeSession) -> Row; | |
| 370 | + | ||
| 371 | + | row when_started(session) { | |
| 372 | + | // A session with no end is one running right now, which is a fact about | |
| 373 | + | // the task and not a missing value. | |
| 374 | + | meta ran_for(session); | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// When a session started, as the row reads it. | |
| 379 | + | fn when_started(session: &TimeSession) -> String { | |
| 380 | + | format!( | |
| 346 | 381 | "{} {}", | |
| 347 | 382 | short_date(session.started_at), | |
| 348 | - | started.format("%-I:%M %p") | |
| 349 | - | )) | |
| 350 | - | .meta(match session.duration_minutes { | |
| 351 | - | Some(minutes) => format!("{minutes}m"), | |
| 352 | - | // A session with no end is one running right now, which is a fact | |
| 353 | - | // about the task and not a missing value. | |
| 354 | - | None => "active".to_owned(), | |
| 355 | - | }) | |
| 383 | + | session.started_at.with_timezone(&Local).format("%-I:%M %p") | |
| 384 | + | ) | |
| 356 | 385 | } | |
| 357 | 386 | ||
| 358 | - | /// The time-tracking section. | |
| 359 | - | /// | |
| 360 | - | /// The site that decided `Meter`'s shape. This bar is toned, red past the | |
| 361 | - | /// estimate and green under it, and it is the one place in either app where the | |
| 362 | - | /// numerator can exceed the denominator, which is why the member carries the | |
| 363 | - | /// pair and not the percentage `Task::time_progress` computes: that | |
| 364 | - | /// function clamps to 100 and the over-run survives only in the separate | |
| 365 | - | /// `is_over_estimate` flag beside it. | |
| 366 | - | /// | |
| 367 | - | /// So the heading keeps the readable summary and the meter carries the numbers | |
| 368 | - | /// unclamped. ", over" stays in the heading text: the meter says it to a | |
| 369 | - | /// renderer through `data-over`, and the heading says it to someone reading. | |
| 370 | - | fn time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node> { | |
| 371 | - | let tracked = format!("{}m tracked", task.actual_minutes); | |
| 372 | - | let label = match task.estimated_minutes { | |
| 373 | - | Some(estimate) if estimate > 0 => { | |
| 374 | - | let over = if task.is_over_estimate() { | |
| 375 | - | ", over" | |
| 376 | - | } else { | |
| 377 | - | "" | |
| 378 | - | }; | |
| 379 | - | format!("{tracked} / {estimate}m est{over}") | |
| 387 | + | /// How long it ran, or that it still is. | |
| 388 | + | fn ran_for(session: &TimeSession) -> String { | |
| 389 | + | session | |
| 390 | + | .duration_minutes | |
| 391 | + | .map_or_else(|| "active".to_owned(), |minutes| format!("{minutes}m")) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | declare! { | |
| 395 | + | /// The time-tracking section. | |
| 396 | + | /// | |
| 397 | + | /// The site that decided `Meter`'s shape. This bar is toned, red past the |
Lines truncated