max / goingson
5 files changed,
+837 insertions,
-954 deletions
| @@ -4745,7 +4745,7 @@ | |||
| 4745 | 4745 | ||
| 4746 | 4746 | [[package]] | |
| 4747 | 4747 | name = "quasi-router" | |
| 4748 | - | version = "0.101.11" | |
| 4748 | + | version = "0.101.12" | |
| 4749 | 4749 | dependencies = [ | |
| 4750 | 4750 | "makeover-layout", | |
| 4751 | 4751 | ] |
| @@ -29,8 +29,9 @@ | |||
| 29 | 29 | #![allow(clippy::needless_pass_by_value)] | |
| 30 | 30 | ||
| 31 | 31 | use goingson_core::{Contact, ContactId}; | |
| 32 | - | use quasi_router::screen::{Act, Row, Tag}; | |
| 33 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 32 | + | use quasi_declare::declare; | |
| 33 | + | use quasi_router::screen::Tag; | |
| 34 | + | use quasi_router::{Action, Node, Response, RouteError, Router}; | |
| 34 | 35 | ||
| 35 | 36 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 36 | 37 | ||
| @@ -52,87 +53,160 @@ | |||
| 52 | 53 | } | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | - | /// One contact as a row. | |
| 56 | - | /// | |
| 57 | - | /// # Two findings, both closed by makeover-layout 0.9.0 | |
| 58 | - | /// | |
| 59 | - | /// This card was the evidence for two gaps when the screen was first described, | |
| 60 | - | /// and both are now said properly rather than worked around. | |
| 61 | - | /// | |
| 62 | - | /// **The tags were joined into `meta` as text**, behind the primary email, | |
| 63 | - | /// because a row had one trailing slot. They are [`Tag`]s now, against | |
| 64 | - | /// `RowPart::Tokens`, and each one is a chip that filters the grid by itself — | |
| 65 | - | /// which is what `contacts.js` does when a badge is clicked, and which the | |
| 66 | - | /// joined string could not express at all. The email stays in `meta`, where a | |
| 67 | - | /// plain fact belongs. | |
| 68 | - | /// | |
| 69 | - | /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the detail | |
| 70 | - | /// pane is showing this", so there was one word for the app's pointer and the | |
| 71 | - | /// user's tick. The two are now `current` and `selected`, and this row is | |
| 72 | - | /// selectable because the screen has bulk actions. | |
| 73 | - | /// | |
| 74 | - | /// # What is still absent, and correctly | |
| 75 | - | /// | |
| 76 | - | /// **The avatar.** `getInitials` derives two letters from the display name and | |
| 77 | - | /// the card shows them in a circle. That is a rendering of the primary text | |
| 78 | - | /// rather than a fact about the contact, so it belongs to the renderer and there | |
| 79 | - | /// is nothing for a description to say. Recorded because it is absent by being | |
| 80 | - | /// correct, not by being missing. | |
| 81 | - | fn row_for(contact: &Contact, current: bool, ticked: bool) -> Row { | |
| 82 | - | let name = match contact.nickname.as_deref() { | |
| 56 | + | /// The name a contact is filed under, with the nickname it goes by. | |
| 57 | + | fn filed_as(contact: &Contact) -> String { | |
| 58 | + | match contact.nickname.as_deref() { | |
| 83 | 59 | Some(nickname) if !nickname.is_empty() => { | |
| 84 | 60 | format!("{} \"{}\"", contact.display_name, nickname) | |
| 85 | 61 | } | |
| 86 | 62 | _ => contact.display_name.clone(), | |
| 87 | - | }; | |
| 88 | - | ||
| 89 | - | let mut row = Row::new(name).selectable(ticked); | |
| 90 | - | ||
| 91 | - | if let Some(affiliation) = affiliation(contact) { | |
| 92 | - | row = row.secondary(affiliation); | |
| 93 | 63 | } | |
| 94 | - | ||
| 95 | - | if let Some(email) = contact.primary_email() { | |
| 96 | - | row = row.meta(email); | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | for tag in &contact.tags { | |
| 100 | - | // A tag filters the grid by itself, which is the click `contacts.js` | |
| 101 | - | // already binds on the same badge. Not latched: a row's tag says what | |
| 102 | - | // the contact carries, and whether that tag is the active filter is the | |
| 103 | - | // band's business rather than this row's. | |
| 104 | - | row = row.token(Tag::chip(tag, list_action(None, Some(tag)))); | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | row.current = current; | |
| 108 | - | row.activate = Some(Action::get(format!("/contacts/{}", contact.id))); | |
| 109 | - | row | |
| 110 | 64 | } | |
| 111 | 65 | ||
| 112 | - | /// The grid, filtered the way the screen's search box and tag filter filter it. | |
| 66 | + | declare! { | |
| 67 | + | /// One contact as a row. | |
| 68 | + | /// | |
| 69 | + | /// # Two findings, both closed by makeover-layout 0.9.0 | |
| 70 | + | /// | |
| 71 | + | /// This card was the evidence for two gaps when the screen was first | |
| 72 | + | /// described, and both are now said properly rather than worked around. | |
| 73 | + | /// | |
| 74 | + | /// **The tags were joined into `meta` as text**, behind the primary email, | |
| 75 | + | /// because a row had one trailing slot. They are [`Tag`]s now, against | |
| 76 | + | /// `RowPart::Tokens`, and each one is a chip that filters the grid by | |
| 77 | + | /// itself -- which is what `contacts.js` does when a badge is clicked, and | |
| 78 | + | /// which the joined string could not express at all. Not latched: a row's | |
| 79 | + | /// tag says what the contact carries, and whether that tag is the active | |
| 80 | + | /// filter is the band's business rather than this row's. The email stays in | |
| 81 | + | /// `meta`, where a plain fact belongs. | |
| 82 | + | /// | |
| 83 | + | /// **The bulk checkbox had nowhere to go.** `Row::selected` meant "the | |
| 84 | + | /// detail pane is showing this", so there was one word for the app's | |
| 85 | + | /// pointer and the user's tick. The two are now `current` and `selected`, | |
| 86 | + | /// and this row is selectable because the screen has bulk actions. | |
| 87 | + | /// | |
| 88 | + | /// # What is still absent, and correctly | |
| 89 | + | /// | |
| 90 | + | /// **The avatar.** `getInitials` derives two letters from the display name | |
| 91 | + | /// and the card shows them in a circle. That is a rendering of the primary | |
| 92 | + | /// text rather than a fact about the contact, so it belongs to the renderer | |
| 93 | + | /// and there is nothing for a description to say. Recorded because it is | |
| 94 | + | /// absent by being correct, not by being missing. | |
| 95 | + | shape row_for(contact: &Contact) -> Row; | |
| 96 | + | ||
| 97 | + | row filed_as(contact) { | |
| 98 | + | selectable false; | |
| 99 | + | ||
| 100 | + | for affiliation in affiliation(contact).into_iter() { | |
| 101 | + | secondary affiliation; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | for email in contact.primary_email().into_iter() { | |
| 105 | + | meta email; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | for tag in contact.tags.iter() { | |
| 109 | + | token Tag::chip(tag, list_action(None, Some(tag))); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | activate to get "/contacts/{contact.id}"; | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// The grid's contacts, and the filters they were read under. | |
| 113 | 117 | /// | |
| 114 | 118 | /// Implicit contacts stay out, which is `list_filtered`'s own rule and the same | |
| 115 | 119 | /// one the contact list applies: it is a curated surface, and a contact that | |
| 116 | 120 | /// exists only because it was once emailed has not been curated into it. | |
| 117 | - | fn grid(state: &AppState, search: Option<&str>, tag: Option<&str>) -> Result<Node, RouteError> { | |
| 121 | + | struct Listing { | |
| 122 | + | contacts: Vec<Contact>, | |
| 123 | + | search: Option<String>, | |
| 124 | + | tag: Option<String>, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Read the grid the request asks for. | |
| 128 | + | fn read(state: &AppState, request: &quasi_router::Request) -> Result<Listing, RouteError> { | |
| 129 | + | let search = text(&request.carried, "q").map(str::to_owned); | |
| 130 | + | let tag = text(&request.carried, "tag").map(str::to_owned); | |
| 118 | 131 | let contacts = state | |
| 119 | 132 | .contacts | |
| 120 | - | .list_filtered(DESKTOP_USER_ID, search, tag, false) | |
| 133 | + | .list_filtered(DESKTOP_USER_ID, search.as_deref(), tag.as_deref(), false) | |
| 121 | 134 | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 135 | + | Ok(Listing { | |
| 136 | + | contacts, | |
| 137 | + | search, | |
| 138 | + | tag, | |
| 139 | + | }) | |
| 140 | + | } | |
| 122 | 141 | ||
| 123 | - | if contacts.is_empty() { | |
| 124 | - | return Ok(Node::text(match (search, tag) { | |
| 125 | - | (Some(_), _) => "No contacts match that search.", | |
| 126 | - | (None, Some(_)) => "No contacts carry that tag.", | |
| 127 | - | (None, None) => "No contacts yet.", | |
| 128 | - | })); | |
| 142 | + | /// What to say when the filters matched nothing. | |
| 143 | + | fn nothing_here(listing: &Listing) -> &'static str { | |
| 144 | + | match (listing.search.as_deref(), listing.tag.as_deref()) { | |
| 145 | + | (Some(_), _) => "No contacts match that search.", | |
| 146 | + | (None, Some(_)) => "No contacts carry that tag.", | |
| 147 | + | (None, None) => "No contacts yet.", | |
| 129 | 148 | } | |
| 149 | + | } | |
| 130 | 150 | ||
| 131 | - | Ok(Node::list( | |
| 132 | - | contacts | |
| 133 | - | .iter() | |
| 134 | - | .map(|contact| row_for(contact, false, false)), | |
| 135 | - | )) | |
| 151 | + | declare! { | |
| 152 | + | /// The grid, filtered the way the screen's search box and tag filter filter | |
| 153 | + | /// it. | |
| 154 | + | shape grid(listing: &Listing) -> Node; | |
| 155 | + | ||
| 156 | + | given listing.contacts.is_empty() { | |
| 157 | + | true -> text nothing_here(listing); | |
| 158 | + | otherwise -> list { | |
| 159 | + | for contact in listing.contacts.iter() { | |
| 160 | + | include row_for(contact); | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Whether the band's chip for this tag is the one in force. | |
| 167 | + | fn tag_latched(listing: &Listing, offered: &str) -> bool { | |
| 168 | + | listing.tag.as_deref() == Some(offered) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// The tag a press on this chip leaves the grid filtered to. | |
| 172 | + | /// | |
| 173 | + | /// A tag that is filtered on stays offered even if it is the only one left, so | |
| 174 | + | /// the way back is always on screen: pressing a latched chip clears it. | |
| 175 | + | fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> { | |
| 176 | + | (!tag_latched(listing, offered)).then_some(offered) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | declare! { | |
| 180 | + | /// The whole screen. | |
| 181 | + | /// | |
| 182 | + | /// The tag filter surfaces only when there are tags to filter by, which is | |
| 183 | + | /// the rule `contacts.js` already applies to the same control; an empty | |
| 184 | + | /// `offered` draws no chips at all. | |
| 185 | + | shape screen(listing: &Listing, offered: &[String]) -> Screen; | |
| 186 | + | ||
| 187 | + | screen list_detail "Contacts" false { | |
| 188 | + | at_place super::shell::CONTACTS; | |
| 189 | + | ||
| 190 | + | region "contacts-band" as Band { | |
| 191 | + | page "Contacts"; | |
| 192 | + | act "New contact" to get "/contacts/new"; | |
| 193 | + | ||
| 194 | + | for in_use in offered.iter() { | |
| 195 | + | chip in_use | |
| 196 | + | to doing list_action(listing.search.as_deref(), cleared(listing, in_use)) { | |
| 197 | + | latched tag_latched(listing, in_use); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | region "contacts-grid" as Pane { | |
| 203 | + | include grid(listing); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | region "contacts-detail" as Pane { | |
| 207 | + | empty "Nothing selected"; | |
| 208 | + | } | |
| 209 | + | } | |
| 136 | 210 | } | |
| 137 | 211 | ||
| 138 | 212 | /// Every tag in use, in a stable order, so the filter is a list and not a guess. | |
| @@ -186,96 +260,105 @@ | |||
| 186 | 260 | Ok(T::from(uuid)) | |
| 187 | 261 | } | |
| 188 | 262 | ||
| 189 | - | /// The whole screen. | |
| 263 | + | /// The whole screen, as an answer. | |
| 190 | 264 | fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 191 | - | let search = text(&request.carried, "q"); | |
| 192 | - | let tag = text(&request.carried, "tag"); | |
| 193 | - | ||
| 194 | - | let mut band = Slot::new("contacts-band", RegionKind::Band) | |
| 195 | - | .with(Node::page("Contacts")) | |
| 196 | - | .with(Node::act("New contact", Action::get("/contacts/new"))); | |
| 197 | - | ||
| 198 | - | // The tag filter surfaces only when there are tags to filter by, which is | |
| 199 | - | // the rule `contacts.js` already applies to the same control. A tag that is | |
| 200 | - | // filtered on stays offered even if it is the only one left, so the way back | |
| 201 | - | // is always on screen. | |
| 202 | - | for in_use in tags_in_use(state)? { | |
| 203 | - | let latched = tag == Some(in_use.as_str()); | |
| 204 | - | let action = list_action(search, (!latched).then_some(in_use.as_str())); | |
| 205 | - | band = band.with(Node::Token(Tag::chip(&in_use, action).latched(latched))); | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | Ok(Screen::list_detail("Contacts", false) | |
| 209 | - | .at_place(super::shell::CONTACTS) | |
| 210 | - | .with(band) | |
| 211 | - | .with(Slot::new("contacts-grid", RegionKind::Pane).with(grid(state, search, tag)?)) | |
| 212 | - | .with(Slot::new("contacts-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))) | |
| 213 | - | .into()) | |
| 265 | + | let listing = read(state, &request)?; | |
| 266 | + | Ok(screen(&listing, &tags_in_use(state)?).into()) | |
| 214 | 267 | } | |
| 215 | 268 | ||
| 216 | 269 | /// The grid alone, which is what search and the tag filter replace. | |
| 217 | 270 | fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 218 | - | let node = grid( | |
| 219 | - | state, | |
| 220 | - | text(&request.carried, "q"), | |
| 221 | - | text(&request.carried, "tag"), | |
| 222 | - | )?; | |
| 223 | - | Ok(Response::fragment("contacts-grid", node)) | |
| 271 | + | Ok(Response::fragment( | |
| 272 | + | "contacts-grid", | |
| 273 | + | grid(&read(state, &request)?), | |
| 274 | + | )) | |
| 224 | 275 | } | |
| 225 | 276 | ||
| 226 | - | /// One entry in a sub-collection, with the control that removes it. | |
| 277 | + | /// One entry in a contact's sub-collections. | |
| 227 | 278 | /// | |
| 228 | - | /// # The third finding, also closed | |
| 229 | - | /// | |
| 230 | - | /// A social handle and a custom field both carry an optional `url`, and the | |
| 231 | - | /// modal renders each as an anchor through `safeUrl`. When this screen was first | |
| 232 | - | /// described there was nothing to say about that: an `Action` was a route, and an | |
| 233 | - | /// address outside the app is not one. The URL went into the trailing text, | |
| 234 | - | /// which made it something to copy rather than something to follow. | |
| 235 | - | /// | |
| 236 | - | /// [`Action::external`] is the fix, and the renderer emits an anchor for it | |
| 237 | - | /// rather than a button. Note what it is *not*: an `Act` whose path happens to | |
| 238 | - | /// start with `https`. The renderer branches on the destination's variant, never | |
| 239 | - | /// on the shape of the string, because that is how a route called | |
| 240 | - | /// `/https-setup` ends up opening a browser. | |
| 241 | - | fn link_row(primary: String, label: Option<&str>, url: Option<&str>, remove: Action) -> Row { | |
| 242 | - | let mut row = Row::new(primary); | |
| 279 | + | /// One struct for all four, because the four differ in what fills it and not in | |
| 280 | + | /// what a row of it says. That is what retired the two shapes that were here: a | |
| 281 | + | /// row builder per pair of collections, and a titled-list wrapper that took | |
| 282 | + | /// rows and handed back nodes. | |
| 283 | + | struct Entry { | |
| 284 | + | /// What the row reads. | |
| 285 | + | text: String, | |
| 286 | + | /// The trailing fact: the label, and whether it is the primary one. | |
| 287 | + | meta: String, | |
| 288 | + | /// Where it goes outside the app, if it goes anywhere. | |
| 289 | + | url: Option<String>, | |
| 290 | + | /// The path that takes it off the contact. | |
| 291 | + | remove: String, | |
| 292 | + | } | |
| 243 | 293 | ||
| 244 | - | if let Some(label) = label { | |
| 245 | - | row = row.meta(label); | |
| 294 | + | /// The label and the primary mark, joined the way the modal joins them. | |
| 295 | + | fn entry_meta(label: &str, primary: bool) -> String { | |
| 296 | + | let label = (!label.is_empty()).then_some(label); | |
| 297 | + | let primary = primary.then_some("Primary"); | |
| 298 | + | [label, primary] | |
| 299 | + | .into_iter() | |
| 300 | + | .flatten() | |
| 301 | + | .collect::<Vec<_>>() | |
| 302 | + | .join(" · ") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Whether the entry has a trailing fact. | |
| 306 | + | fn has_meta(entry: &Entry) -> bool { | |
| 307 | + | !entry.meta.is_empty() | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | declare! { | |
| 311 | + | /// One entry in a sub-collection, with the control that removes it. | |
| 312 | + | /// | |
| 313 | + | /// # The third finding, also closed | |
| 314 | + | /// | |
| 315 | + | /// A social handle and a custom field both carry an optional `url`, and the | |
| 316 | + | /// modal renders each as an anchor through `safeUrl`. When this screen was | |
| 317 | + | /// first described there was nothing to say about that: an `Action` was a | |
| 318 | + | /// route, and an address outside the app is not one. The URL went into the | |
| 319 | + | /// trailing text, which made it something to copy rather than something to | |
| 320 | + | /// follow. | |
| 321 | + | /// | |
| 322 | + | /// [`Action::external`] is the fix, and the renderer emits an anchor for it | |
| 323 | + | /// rather than a button. Note what it is *not*: an `Act` whose path happens | |
| 324 | + | /// to start with `https`. The renderer branches on the destination's | |
| 325 | + | /// variant, never on the shape of the string, because that is how a route | |
| 326 | + | /// called `/https-setup` ends up opening a browser. | |
| 327 | + | /// | |
| 328 | + | /// The address is a place to go rather than a fact about the entry, so it | |
| 329 | + | /// is a control and not trailing text. | |
| 330 | + | shape entry_row(entry: &Entry) -> Row; | |
| 331 | + | ||
| 332 | + | row &entry.text { | |
| 333 | + | meta &entry.meta when has_meta(entry); | |
| 334 | + | ||
| 335 | + | for url in entry.url.iter() { | |
| 336 | + | act "Open" to external url; | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | act "Remove" to post "{entry.remove}" { | |
| 340 | + | tone Danger; | |
| 341 | + | } | |
| 246 | 342 | } | |
| 247 | - | ||
| 248 | - | // The address is a place to go rather than a fact about the entry, so it is | |
| 249 | - | // a control and not trailing text. | |
| 250 | - | if let Some(url) = url { | |
| 251 | - | row = row.act(Act::new("Open", Action::external(url))); | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | row.act(Act::new("Remove", remove).tone(makeover_layout::Tone::Danger)) | |
| 255 | 343 | } | |
| 256 | 344 | ||
| 257 | - | /// A sub-collection as a titled list, or a line saying it is empty. | |
| 258 | - | fn sub_collection(title: &str, empty: &str, rows: Vec<Row>) -> Vec<Node> { | |
| 259 | - | let body = if rows.is_empty() { | |
| 260 | - | Node::text(empty) | |
| 261 | - | } else { | |
| 262 | - | Node::list(rows) | |
| 263 | - | }; | |
| 264 | - | vec![Node::section(title), body] | |
| 345 | + | /// The detail pane's contact, with its four sub-collections read out as rows. | |
| 346 | + | struct Shown { | |
| 347 | + | contact: Contact, | |
| 348 | + | /// The facts the modal lists one per row, the present ones only. | |
| 349 | + | facts: Vec<String>, | |
| 350 | + | emails: Vec<Entry>, | |
| 351 | + | phones: Vec<Entry>, | |
| 352 | + | socials: Vec<Entry>, | |
| 353 | + | fields: Vec<Entry>, | |
| 265 | 354 | } | |
| 266 | 355 | ||
| 267 | - | /// The detail pane for one contact, which is also what a removal answers with. | |
| 268 | - | fn detail_pane(contact: &Contact) -> Node { | |
| 356 | + | /// Everything the pane draws, worked out once. | |
| 357 | + | fn shown(contact: Contact) -> Shown { | |
| 269 | 358 | let id = contact.id; | |
| 270 | - | let mut slot = | |
| 271 | - | Slot::new("contacts-detail", RegionKind::Pane).with(Node::section(&contact.display_name)); | |
| 272 | 359 | ||
| 273 | - | if let Some(affiliation) = affiliation(contact) { | |
| 274 | - | slot = slot.with(Node::text(affiliation)); | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | // The facts the modal lists one per row. Rendered only when present, which | |
| 278 | - | // is what `showDetailModal` does with the same five. | |
| 360 | + | // The facts the modal lists one per row. Present ones only, which is what | |
| 361 | + | // `showDetailModal` does with the same five. | |
| 279 | 362 | let mut facts: Vec<String> = Vec::new(); | |
| 280 | 363 | if let Some(nickname) = contact.nickname.as_deref().filter(|n| !n.is_empty()) { | |
| 281 | 364 | facts.push(format!("Nickname: {nickname}")); | |
| @@ -289,105 +372,122 @@ | |||
| 289 | 372 | if !contact.tags.is_empty() { | |
| 290 | 373 | facts.push(format!("Tags: {}", contact.tags.join(", "))); | |
| 291 | 374 | } | |
| 292 | - | for fact in facts { | |
| 293 | - | slot = slot.with(Node::text(fact)); | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | if !contact.notes.is_empty() { | |
| 297 | - | slot = slot.with(Node::section("Notes")); | |
| 298 | - | slot = slot.with(Node::text(&contact.notes)); | |
| 299 | - | } | |
| 300 | 375 | ||
| 301 | 376 | let emails = contact | |
| 302 | 377 | .emails | |
| 303 | 378 | .iter() | |
| 304 | - | .map(|email| { | |
| 305 | - | let label = (!email.label.is_empty()).then_some(email.label.as_str()); | |
| 306 | - | let primary = email.is_primary.then_some("Primary"); | |
| 307 | - | let meta = [label, primary] | |
| 308 | - | .into_iter() | |
| 309 | - | .flatten() | |
| 310 | - | .collect::<Vec<_>>() | |
| 311 | - | .join(" · "); | |
| 312 | - | let mut row = Row::new(&email.address); | |
| 313 | - | if !meta.is_empty() { | |
| 314 | - | row = row.meta(meta); | |
| 315 | - | } | |
| 316 | - | row.act( | |
| 317 | - | Act::new( | |
| 318 | - | "Remove", | |
| 319 | - | Action::post(format!("/contacts/{id}/email/{}/delete", email.id)), | |
| 320 | - | ) | |
| 321 | - | .tone(makeover_layout::Tone::Danger), | |
| 322 | - | ) | |
| 379 | + | .map(|email| Entry { | |
| 380 | + | text: email.address.clone(), | |
| 381 | + | meta: entry_meta(&email.label, email.is_primary), | |
| 382 | + | url: None, | |
| 383 | + | remove: format!("/contacts/{id}/email/{}/delete", email.id), | |
| 323 | 384 | }) | |
| 324 | 385 | .collect(); | |
| 325 | 386 | ||
| 326 | 387 | let phones = contact | |
| 327 | 388 | .phones | |
| 328 | 389 | .iter() | |
| 329 | - | .map(|phone| { | |
| 330 | - | let label = (!phone.label.is_empty()).then_some(phone.label.as_str()); | |
| 331 | - | let primary = phone.is_primary.then_some("Primary"); | |
| 332 | - | let meta = [label, primary] | |
| 333 | - | .into_iter() | |
| 334 | - | .flatten() | |
| 335 | - | .collect::<Vec<_>>() | |
| 336 | - | .join(" · "); | |
| 337 | - | let mut row = Row::new(&phone.number); | |
| 338 | - | if !meta.is_empty() { | |
| 339 | - | row = row.meta(meta); | |
| 340 | - | } | |
| 341 | - | row.act( | |
| 342 | - | Act::new( | |
| 343 | - | "Remove", | |
| 344 | - | Action::post(format!("/contacts/{id}/phone/{}/delete", phone.id)), | |
| 345 | - | ) | |
| 346 | - | .tone(makeover_layout::Tone::Danger), | |
| 347 | - | ) | |
| 390 | + | .map(|phone| Entry { | |
| 391 | + | text: phone.number.clone(), | |
| 392 | + | meta: entry_meta(&phone.label, phone.is_primary), | |
| 393 | + | url: None, | |
| 394 | + | remove: format!("/contacts/{id}/phone/{}/delete", phone.id), | |
| 348 | 395 | }) | |
| 349 | 396 | .collect(); | |
| 350 | 397 | ||
| 351 | 398 | let socials = contact | |
| 352 | 399 | .social_handles | |
| 353 | 400 | .iter() | |
| 354 | - | .map(|handle| { | |
| 355 | - | link_row( | |
| 356 | - | format!("{}: {}", handle.platform, handle.handle), | |
| 357 | - | None, | |
| 358 | - | handle.url.as_deref(), | |
| 359 | - | Action::post(format!("/contacts/{id}/social/{}/delete", handle.id)), | |
| 360 | - | ) | |
| 401 | + | .map(|handle| Entry { | |
| 402 | + | text: format!("{}: {}", handle.platform, handle.handle), | |
| 403 | + | meta: String::new(), | |
| 404 | + | url: handle.url.clone(), |
Lines truncated
| @@ -37,11 +37,12 @@ | |||
| 37 | 37 | ||
| 38 | 38 | use chrono::{Local, NaiveDate, TimeZone, Utc}; | |
| 39 | 39 | use goingson_core::TimelineItem; | |
| 40 | - | use makeover_layout::{FieldKind, Placement, Tone, Track}; | |
| 41 | - | use quasi_router::screen::{Act, Choice, Field, Placed, Row, Tag}; | |
| 42 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 40 | + | use makeover_layout::{Placement, Tone, Track}; | |
| 41 | + | use quasi_declare::declare; | |
| 42 | + | use quasi_router::screen::{Choice, Tag}; | |
| 43 | + | use quasi_router::{Response, RouteError, Router, Slot}; | |
| 43 | 44 | ||
| 44 | - | use crate::commands::{DayPlanningResponse, TaskResponse, day_plan}; | |
| 45 | + | use crate::commands::{ContextResponse, DayPlanningResponse, TaskResponse, day_plan}; | |
| 45 | 46 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 46 | 47 | ||
| 47 | 48 | #[cfg(test)] | |
| @@ -69,7 +70,7 @@ | |||
| 69 | 70 | /// | |
| 70 | 71 | /// One number for both, because they answer the same question: which part of | |
| 71 | 72 | /// the day the reader is most likely to mean. | |
| 72 | - | const FOCUS_MINUTES: i32 = 9 * 60; | |
| 73 | + | const FOCUS_MINUTES: u16 = 9 * 60; | |
| 73 | 74 | ||
| 74 | 75 | /// The grid a placement lands on, in minutes. | |
| 75 | 76 | /// | |
| @@ -105,293 +106,364 @@ | |||
| 105 | 106 | .collect() | |
| 106 | 107 | } | |
| 107 | 108 | ||
| 108 | - | /// Putting a task on the day, as the control that does it. | |
| 109 | + | /// Whether the task already says how long it takes. | |
| 109 | 110 | /// | |
| 110 | - | /// A task is placed at its own size: the block is as long as the estimate, so | |
| 111 | - | /// the day shows the work rather than a row of identical stubs. A task with no | |
| 112 | - | /// estimate is placeable anyway, and **placing it is what sets the estimate**. | |
| 113 | - | /// goingson `fa9fe9ed` ruled (d) over defaulting silently, over refusing the | |
| 114 | - | /// placement, and over drawing a guess differently. The planner is the one place a | |
| 115 | - | /// person is already thinking about how long the thing takes, so it is the | |
| 116 | - | /// right place to be asked. | |
| 117 | - | /// | |
| 118 | - | /// Both halves are [`Act::asking`], which existed: a control that asks for a | |
| 119 | - | /// value before it acts. The second field is offered only when there is | |
| 120 | - | /// nothing to offer instead, so a task that carries an estimate is placed in | |
| 121 | - | /// one answer rather than being asked to confirm what it already says. | |
| 122 | - | fn placing(task: &TaskResponse, date: NaiveDate) -> Act { | |
| 123 | - | let mut act = Act::new( | |
| 124 | - | "Place on the day", | |
| 125 | - | Action::post(format!("/day/{date}/schedule/{}/place", task.id)), | |
| 126 | - | ) | |
| 127 | - | .asking( | |
| 128 | - | Field::select("at", "Start at", slots()) | |
| 129 | - | .value(FOCUS_MINUTES.to_string()) | |
| 130 | - | .required(), | |
| 131 | - | ); | |
| 132 | - | ||
| 133 | - | if task.estimated_minutes.is_none_or(|minutes| minutes <= 0) { | |
| 134 | - | act = act.asking( | |
| 135 | - | Field { | |
| 136 | - | min: Some("1".to_owned()), | |
| 137 | - | max: Some(DAY_MINUTES.to_string()), | |
| 138 | - | value: Some(UNESTIMATED_MINUTES.to_string()), | |
| 139 | - | ..Field::new(FieldKind::Number, "minutes", "How long it takes") | |
| 140 | - | } | |
| 141 | - | .hint("Placing it records this as the task's estimate.") | |
| 142 | - | .required(), | |
| 143 | - | ); | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | act | |
| 111 | + | /// A stored zero is no estimate: `is_over_estimate` reads it that way and a | |
| 112 | + | /// block of no length is not a thing the axis can draw. | |
| 113 | + | fn estimated(task: &TaskResponse) -> bool { | |
| 114 | + | task.estimated_minutes.is_some_and(|minutes| minutes > 0) | |
| 147 | 115 | } | |
| 148 | 116 | ||
| 149 | - | /// One item as a row, without its placement. | |
| 150 | - | /// | |
| 151 | - | /// The whole body is vocabulary that existed before this screen: a title, the | |
| 152 | - | /// project it belongs to, a tone, and a chip when the item continues past the | |
| 153 | - | /// edge of the day. That is the measurement the timeline refusal never took. | |
| 154 | - | fn row_for(item: &TimelineItem, conflicted: bool, date: NaiveDate) -> Row { | |
| 155 | - | let mut row = Row::new(&item.title); | |
| 117 | + | declare! { | |
| 118 | + | /// Putting a task on the day, as the control that does it. | |
| 119 | + | /// | |
| 120 | + | /// A task is placed at its own size: the block is as long as the estimate, | |
| 121 | + | /// so the day shows the work rather than a row of identical stubs. A task | |
| 122 | + | /// with no estimate is placeable anyway, and **placing it is what sets the | |
| 123 | + | /// estimate**. goingson `fa9fe9ed` ruled (d) over defaulting silently, over | |
| 124 | + | /// refusing the placement, and over drawing a guess differently. The | |
| 125 | + | /// planner is the one place a person is already thinking about how long the | |
| 126 | + | /// thing takes, so it is the right place to be asked. | |
| 127 | + | /// | |
| 128 | + | /// Both halves are [`Act::asking`]: a control that asks for a value before | |
| 129 | + | /// it acts. The second question is asked only when there is nothing to ask | |
| 130 | + | /// it about instead, so a task that carries an estimate is placed in one | |
| 131 | + | /// answer rather than being asked to confirm what it already says. | |
| 132 | + | shape placing(task: &TaskResponse, date: NaiveDate) -> Act; | |
| 156 | 133 | ||
| 157 | - | if let Some(project) = &item.project_name { | |
| 158 | - | row = row.meta(project); | |
| 159 | - | } | |
| 134 | + | act "Place on the day" to post "/day/{date}/schedule/{task.id}/place" { | |
| 135 | + | field Select "at" "Start at" { | |
| 136 | + | options slots(); | |
| 137 | + | value FOCUS_MINUTES.to_string(); | |
| 138 | + | required; | |
| 139 | + | } | |
| 160 | 140 | ||
| 161 | - | // An event that runs over midnight is one row and two bars, and the bar | |
| 162 | - | // this day draws is clipped to this day. The chip says the clipping | |
| 163 | - | // happened, in words rather than with a drawing. | |
| 164 | - | if item.continues_before { | |
| 165 | - | row = row.token(Tag::badge("from earlier").tone(Tone::Neutral)); | |
| 141 | + | field Number "minutes" "How long it takes" unless estimated(task) { | |
| 142 | + | within "1" DAY_MINUTES.to_string(); | |
| 143 | + | value UNESTIMATED_MINUTES.to_string(); | |
| 144 | + | hint "Placing it records this as the task's estimate."; | |
| 145 | + | required; | |
| 146 | + | } | |
| 166 | 147 | } | |
| 167 | - | if item.continues_after { | |
| 168 | - | row = row.token(Tag::badge("continues").tone(Tone::Neutral)); | |
| 169 | - | } | |
| 170 | - | if conflicted { | |
| 171 | - | row = row.token(Tag::badge("clashes").tone(Tone::Danger)); | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // A focus block is a different kind of thing from an appointment. A row | |
| 175 | - | // carries no tone of its own -- | |
| 176 | - | // deliberately, since a whole line in a colour is a slab -- so the fact | |
| 177 | - | // travels on a badge, which is where every other per-row judgment on this | |
| 178 | - | // screen already is. | |
| 179 | - | if item.item_type == "block" { | |
| 180 | - | row = row.token(Tag::badge("block").tone(Tone::Info)); | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // A scheduled task opens the task it stands for, not the event row that | |
| 184 | - | // holds it. `linked_task_id` exists for exactly this: the row's own id is | |
| 185 | - | // the event's, and opening that would address the wrong thing. | |
| 186 | - | if let Some(task) = item.linked_task_id { | |
| 187 | - | row = row.activate(Action::get(format!("/tasks/{task}"))); | |
| 188 | - | ||
| 189 | - | // Moving a scheduled task is a control that steps by a fixed amount, | |
| 190 | - | // the same shape as the milestone row's reorder: a control that is on | |
| 191 | - | // screen beats a gesture nobody discovers. | |
| 192 | - | // | |
| 193 | - | // The drag stays refused and the question is closed, not open: | |
| 194 | - | // quasicoherent `e41079b2` ruled that pre-portioned placement replaces | |
| 195 | - | // it. Placement decides where a block starts; stepping is how it moves | |
| 196 | - | // afterwards, so the two are not alternatives. | |
| 197 | - | let step = |minutes: i32, label: &str| { | |
| 198 | - | Act::new( | |
| 199 | - | label, | |
| 200 | - | Action::post(format!("/day/{date}/schedule/{task}/move")) | |
| 201 | - | .with("by", minutes.to_string()), | |
| 202 | - | ) | |
| 203 | - | }; | |
| 204 | - | row = row | |
| 205 | - | .act(step(-15, "Move earlier")) | |
| 206 | - | .act(step(15, "Move later")) | |
| 207 | - | .act(Act::new( | |
| 208 | - | "Unschedule", | |
| 209 | - | Action::post(format!("/day/{date}/schedule/{task}/unschedule")), | |
| 210 | - | )); | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | row | |
| 214 | 148 | } | |
| 215 | 149 | ||
| 216 | - | /// The axis, and everything placed on it. | |
| 217 | - | fn timeline(response: &DayPlanningResponse, date: NaiveDate) -> Node { | |
| 218 | - | // Which items clash, from the pairs the backend already computes. Collected | |
| 219 | - | // into a set because a row needs to know only whether it is in one, and | |
| 220 | - | // `detect_conflicts` answers in pairs. | |
| 150 | + | /// One thing on the day, and the two facts the plan worked out for it. | |
| 151 | + | struct Entry { | |
| 152 | + | item: TimelineItem, | |
| 153 | + | /// Whether it covers any of the same time as something else. | |
| 154 | + | conflicted: bool, | |
| 155 | + | /// Where it sits on the axis, and for how long. | |
| 156 | + | /// | |
| 157 | + | /// Both already computed by the command, in the units [`Placement`] wants. | |
| 158 | + | /// Nothing was added to the backend for this, which is the sign that what | |
| 159 | + | /// the vocabulary was missing was the ability to *say* it. | |
| 160 | + | placement: Placement, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// One unscheduled task, with what the plan says about it. | |
| 164 | + | struct Waiting { | |
| 165 | + | task: TaskResponse, | |
| 166 | + | /// What it still waits on, said in words. | |
| 167 | + | gate: Option<String>, | |
| 168 | + | /// Whether the plan puts it out of order. | |
| 169 | + | out_of_order: bool, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Everything the day view draws, read once. | |
| 173 | + | struct Day { | |
| 174 | + | date: NaiveDate, | |
| 175 | + | /// What sits on the axis, with where it sits. | |
| 176 | + | on_axis: Vec<Entry>, | |
| 177 | + | /// What covers the whole day, drawn above the axis. | |
| 178 | + | covering: Vec<Entry>, | |
| 179 | + | /// Everything due today that is not on the axis yet. | |
| 180 | + | pool: Vec<Waiting>, | |
| 181 | + | /// The states framing the day. | |
| 182 | + | contexts: Vec<ContextResponse>, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// Read the day. | |
| 186 | + | /// | |
| 187 | + | /// The conflict pairs the backend computes become a set, because a row needs to | |
| 188 | + | /// know only whether it is in one and `detect_conflicts` answers in pairs. | |
| 189 | + | fn read(state: &AppState, date: NaiveDate) -> Result<Day, RouteError> { | |
| 190 | + | let response = plan(state, date)?; | |
| 191 | + | ||
| 221 | 192 | let clashing: std::collections::HashSet<_> = response | |
| 222 | 193 | .conflicts | |
| 223 | 194 | .iter() | |
| 224 | - | .flat_map(|c| [c.item1_id, c.item2_id]) | |
| 195 | + | .flat_map(|conflict| [conflict.item1_id, conflict.item2_id]) | |
| 225 | 196 | .collect(); | |
| 226 | 197 | ||
| 227 | - | let entries: Vec<Placed> = response | |
| 198 | + | let (all_day, on_axis): (Vec<TimelineItem>, Vec<TimelineItem>) = response | |
| 228 | 199 | .timeline_items | |
| 229 | - | .iter() | |
| 200 | + | .into_iter() | |
| 230 | 201 | // An item covering the whole column is not on the axis: a bar over all | |
| 231 | 202 | // 24 hours papers over every real appointment. That is geometry and | |
| 232 | - | // nothing more: a context is its own record and never a timeline item. | |
| 233 | - | .filter(|item| !item.is_all_day) | |
| 234 | - | .map(|item| { | |
| 235 | - | let conflicted = clashing.contains(&item.id); | |
| 236 | - | Placed { | |
| 237 | - | // Both already computed by the command, in the units | |
| 238 | - | // `Placement` wants. Nothing is added to the backend for this, | |
| 239 | - | // which is the sign that what the vocabulary was missing was | |
| 240 | - | // the ability to *say* this rather than anything | |
| 241 | - | // anyone had to work out. | |
| 242 | - | placement: Placement::new( | |
| 243 | - | u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0), | |
| 244 | - | u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1), | |
| 245 | - | ), | |
| 246 | - | row: row_for(item, conflicted, date), | |
| 247 | - | } | |
| 248 | - | }) | |
| 249 | - | .collect(); | |
| 203 | + | // nothing more; a context is its own record and never a timeline item. | |
| 204 | + | .partition(|item| item.is_all_day); | |
| 250 | 205 | ||
| 251 | - | Node::Timeline { | |
| 252 | - | track: Track::DAY, | |
| 253 | - | entries, | |
| 254 | - | // The interesting hour, said as a moment. `day-planning-render.js:321` | |
| 255 | - | // is `const targetHour = 9`, a literal inside the renderer; the app is | |
| 256 | - | // what knows which hour matters and this is where it says so. | |
| 257 | - | focus: Some(9 * 60), | |
| 258 | - | } | |
| 259 | - | } | |
| 206 | + | let placed = |item: TimelineItem, conflicted: bool| Entry { | |
| 207 | + | placement: Placement::new( | |
| 208 | + | u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0), | |
| 209 | + | u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1), | |
| 210 | + | ), | |
| 211 | + | conflicted, | |
| 212 | + | item, | |
| 213 | + | }; | |
| 260 | 214 | ||
| 261 | - | /// The all-day strip above the axis. | |
| 262 | - | /// | |
| 263 | - | /// A separate list rather than entries with a full-day placement, for the | |
| 264 | - | /// reason `is_all_day` is a field: a bar covering the whole span hides | |
| 265 | - | /// everything under it. Absent, not empty, when nothing is all-day. | |
| 266 | - | /// | |
| 267 | - | /// These are occupancies that happen to fill the column, not contexts. A | |
| 268 | - | /// context is drawn in the band as a banner and is not a timeline item at all. | |
| 269 | - | fn all_day(response: &DayPlanningResponse, date: NaiveDate) -> Option<Node> { | |
| 270 | - | let rows: Vec<Row> = response | |
| 271 | - | .timeline_items | |
| 272 | - | .iter() | |
| 273 | - | .filter(|item| item.is_all_day) | |
| 274 | - | .map(|item| row_for(item, false, date)) | |
| 275 | - | .collect(); | |
| 276 | - | ||
| 277 | - | (!rows.is_empty()).then_some(Node::List { rows, more: None }) | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | /// The unscheduled pool. | |
| 281 | - | /// | |
| 282 | - | /// Everything due today that is not on the axis yet. Each row opens its task | |
| 283 | - | /// and carries the control that puts it on the day; see [`placing`] for what | |
| 284 | - | /// that asks for and why. | |
| 285 | - | fn pool(response: &DayPlanningResponse, date: NaiveDate) -> Node { | |
| 286 | - | let rows: Vec<Row> = response | |
| 215 | + | let pool = response | |
| 287 | 216 | .unscheduled_tasks | |
| 288 | - | .iter() | |
| 217 | + | .into_iter() | |
| 289 | 218 | .map(|task| { | |
| 290 | - | let mut row = Row::new(&task.title); | |
| 291 | - | if let Some(project) = &task.project_name { | |
| 292 | - | row = row.meta(project); | |
| 219 | + | let gate = response.gates.get(&task.id); | |
| 220 | + | Waiting { | |
| 221 | + | gate: gate.and_then(|gate| { | |
| 222 | + | gate.after.first().map(|first| { | |
| 223 | + | if gate.after.len() > 1 { | |
| 224 | + | format!("after {} +{}", first.title, gate.after.len() - 1) | |
| 225 | + | } else { | |
| 226 | + | format!("after {}", first.title) | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | }), | |
| 230 | + | out_of_order: gate.is_some_and(|gate| gate.out_of_order), | |
| 231 | + | task, | |
| 293 | 232 | } | |
| 294 | - | // What this task still waits on: a fact about the task, said with | |
| 295 | - | // a badge rather than with a class. | |
| 296 | - | if let Some(gate) = response.gates.get(&task.id) { | |
| 297 | - | if let Some(first) = gate.after.first() { | |
| 298 | - | let label = if gate.after.len() > 1 { | |
| 299 | - | format!("after {} +{}", first.title, gate.after.len() - 1) | |
| 300 | - | } else { | |
| 301 | - | format!("after {}", first.title) | |
| 302 | - | }; | |
| 303 | - | row = row.token(Tag::badge(&label).tone(Tone::Warning)); | |
| 304 | - | } | |
| 305 | - | if gate.out_of_order { | |
| 306 | - | row = row.token(Tag::badge("out of order").tone(Tone::Warning)); | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | // Which of the offered tasks is worth scheduling first, which the | |
| 310 | - | // gate cannot say. Only the frees-work half: see | |
| 311 | - | // [`crate::quasi::Availability::frees_marker`]. | |
| 312 | - | if let Some(marker) = super::Availability::reported(task).frees_marker() { | |
| 313 | - | row = row.token(marker); | |
| 314 | - | } | |
| 315 | - | row.act(placing(task, date)) | |
| 316 | - | .activate(Action::get(format!("/tasks/{}", task.id))) | |
| 317 | 233 | }) | |
| 318 | 234 | .collect(); | |
| 319 | 235 | ||
| 320 | - | if rows.is_empty() { | |
| 321 | - | Node::Text { | |
| 322 | - | text: "Nothing else due today.".into(), | |
| 323 | - | tone: Tone::Neutral, | |
| 236 | + | Ok(Day { | |
| 237 | + | date, | |
| 238 | + | on_axis: on_axis | |
| 239 | + | .into_iter() | |
| 240 | + | .map(|item| { | |
| 241 | + | let conflicted = clashing.contains(&item.id); | |
| 242 | + | placed(item, conflicted) | |
| 243 | + | }) | |
| 244 | + | .collect(), | |
| 245 | + | covering: all_day | |
| 246 | + | .into_iter() | |
| 247 | + | .map(|item| placed(item, false)) | |
| 248 | + | .collect(), | |
| 249 | + | pool, | |
| 250 | + | contexts: response.contexts, | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | declare! { | |
| 255 | + | /// One item as a row, without its placement. | |
| 256 | + | /// | |
| 257 | + | /// The whole body is vocabulary that existed before this screen: a title, | |
| 258 | + | /// the project it belongs to, a tone, and a chip when the item continues | |
| 259 | + | /// past the edge of the day. That is the measurement the timeline refusal | |
| 260 | + | /// never took. | |
| 261 | + | /// | |
| 262 | + | /// An event that runs over midnight is one row and two bars, and the bar | |
| 263 | + | /// this day draws is clipped to this day. The chips say the clipping | |
| 264 | + | /// happened, in words rather than with a drawing. | |
| 265 | + | /// | |
| 266 | + | /// A focus block is a different kind of thing from an appointment. A row | |
| 267 | + | /// carries no tone of its own -- deliberately, since a whole line in a | |
| 268 | + | /// colour is a slab -- so the fact travels on a badge, which is where every | |
| 269 | + | /// other per-row judgment on this screen already is. | |
| 270 | + | /// | |
| 271 | + | /// A scheduled task opens the task it stands for, not the event row that | |
| 272 | + | /// holds it. `linked_task_id` exists for exactly this: the row's own id is | |
| 273 | + | /// the event's, and opening that would address the wrong thing. Moving one | |
| 274 | + | /// is a control that steps by a fixed amount, the same shape as the | |
| 275 | + | /// milestone row's reorder: a control that is on screen beats a gesture | |
| 276 | + | /// nobody discovers. The drag stays refused and the question is closed, not | |
| 277 | + | /// open -- quasicoherent `e41079b2` ruled that pre-portioned placement | |
| 278 | + | /// replaces it, and placement decides where a block starts while stepping | |
| 279 | + | /// is how it moves afterwards. | |
| 280 | + | shape row_for(day: &Day, entry: &Entry) -> Row; | |
| 281 | + | ||
| 282 | + | row &entry.item.title { | |
| 283 | + | for project in entry.item.project_name.iter() { | |
| 284 | + | meta project; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | token Tag::badge("from earlier").tone(Tone::Neutral) when entry.item.continues_before; | |
| 288 | + | token Tag::badge("continues").tone(Tone::Neutral) when entry.item.continues_after; | |
| 289 | + | token Tag::badge("clashes").tone(Tone::Danger) when entry.conflicted; | |
| 290 | + | token Tag::badge("block").tone(Tone::Info) when entry.item.item_type is "block"; | |
| 291 | + | ||
| 292 | + | for task in entry.item.linked_task_id.iter() { | |
| 293 | + | activate to get "/tasks/{task}"; | |
| 294 | + | act "Move earlier" to post "/day/{day.date}/schedule/{task}/move" with "by" "-15"; | |
| 295 | + | act "Move later" to post "/day/{day.date}/schedule/{task}/move" with "by" "15"; | |
| 296 | + | act "Unschedule" to post "/day/{day.date}/schedule/{task}/unschedule"; | |
| 324 | 297 | } | |
| 325 | - | } else { | |
| 326 | - | Node::List { rows, more: None } | |
| 327 | 298 | } | |
| 328 | 299 | } | |
| 329 | 300 | ||
| 330 | - | /// The step-a-day controls and the date, as a band. | |
| 331 | - | fn band(date: NaiveDate, response: &DayPlanningResponse) -> Slot { | |
| 332 | - | let mut band = Slot::new("day-band", RegionKind::Band) | |
| 333 | - | .with(Node::Heading { | |
| 334 | - | level: makeover_layout::Heading::Page, | |
| 335 | - | text: date.format("%A, %-d %B").to_string(), | |
| 336 | - | }) | |
| 337 | - | .with(Node::Token(Tag::chip( | |
| 338 | - | "Previous", | |
| 339 | - | Action::get(format!("/day/{}", date.pred_opt().unwrap_or(date))), | |
| 340 | - | ))) | |
| 341 | - | .with(Node::Token(Tag::chip( | |
| 342 | - | "Next", | |
| 343 | - | Action::get(format!("/day/{}", date.succ_opt().unwrap_or(date))), | |
| 344 | - | ))); | |
| 301 | + | declare! { | |
| 302 | + | /// The axis, and everything placed on it. | |
| 303 | + | /// | |
| 304 | + | /// The focus is the interesting hour, said as a moment. | |
| 305 | + | /// `day-planning-render.js:321` is `const targetHour = 9`, a literal inside | |
| 306 | + | /// the renderer; the app is what knows which hour matters and this is where | |
| 307 | + | /// it says so. | |
| 308 | + | shape timeline(day: &Day) -> Node; | |
| 345 | 309 | ||
| 346 | - | // What frames the day says so once, at the top, rather than by greying the | |
| 347 | - | // axis. One banner per context: they are states the reader is in rather | |
| 348 | - | // than things on the timeline, so they sit behind the day rather than on | |
| 349 | - | // it. One per context, named: a trip, an illness and a sprint each say | |
| 350 | - | // what they are. | |
| 351 | - | for context in &response.contexts { | |
| 352 | - | band = band.with(Node::banner(Tone::Info, context.label.clone())); | |
| 310 | + | timeline Track::DAY { | |
| 311 | + | focus FOCUS_MINUTES; | |
| 312 | + | ||
| 313 | + | for entry in day.on_axis.iter() { | |
| 314 | + | at entry.placement include row_for(day, entry); | |
| 315 | + | } | |
| 353 | 316 | } | |
| 354 | - | ||
| 355 | - | // The way to the screen that authors them, beside the banners rather than | |
| 356 | - | // on one. A notice can carry an act now, and this is still not one: | |
| 357 | - | // recording a context is most often done for days you are not looking at, | |
| 358 | - | // so the way in has to be there whether or not a banner is. | |
| 359 | - | band = band.with(Node::Token(Tag::chip("Contexts", Action::get("/contexts")))); | |
| 360 | - | ||
| 361 | - | band | |
| 362 | 317 | } | |
| 363 | 318 | ||
| 364 | - | /// The whole day. | |
| 319 | + | declare! { | |
| 320 | + | /// The all-day strip above the axis. | |
| 321 | + | /// | |
| 322 | + | /// A separate list rather than entries with a full-day placement, for the | |
| 323 | + | /// reason `is_all_day` is a field: a bar covering the whole span hides | |
| 324 | + | /// everything under it. Absent, not empty, when nothing is all-day. | |
| 325 | + | /// | |
| 326 | + | /// These are occupancies that happen to fill the column, not contexts. A | |
| 327 | + | /// context is drawn in the band as a banner and is not a timeline item at | |
| 328 | + | /// all. | |
| 329 | + | shape all_day(day: &Day) -> Option<Node>; | |
| 330 | + | ||
| 331 | + | list { | |
| 332 | + | for entry in day.covering.iter() { | |
| 333 | + | include row_for(day, entry); | |
| 334 | + | } | |
| 335 | + | } unless day.covering.is_empty(); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | declare! { | |
| 339 | + | /// The unscheduled pool. | |
| 340 | + | /// | |
| 341 | + | /// Everything due today that is not on the axis yet. Each row opens its | |
| 342 | + | /// task and carries the control that puts it on the day; see [`placing`] | |
| 343 | + | /// for what that asks for and why. | |
| 344 | + | /// | |
| 345 | + | /// What a task still waits on is a fact about the task, said with a badge | |
| 346 | + | /// rather than with a class. Which of the offered tasks is worth scheduling | |
| 347 | + | /// first is what the gate cannot say, and only the frees-work half of it | |
| 348 | + | /// is drawn: see [`crate::quasi::Availability::frees_marker`]. | |
| 349 | + | shape pool(day: &Day) -> Node; | |
| 350 | + | ||
| 351 | + | given day.pool.is_empty() { | |
| 352 | + | true -> text "Nothing else due today."; | |
| 353 | + | otherwise -> list { | |
| 354 | + | for waiting in day.pool.iter() { | |
| 355 | + | row &waiting.task.title { | |
| 356 | + | for project in waiting.task.project_name.iter() { |
Lines truncated
| @@ -27,11 +27,12 @@ | |||
| 27 | 27 | #![allow(clippy::needless_pass_by_value)] | |
| 28 | 28 | ||
| 29 | 29 | use chrono::{Datelike, NaiveDate}; | |
| 30 | - | use goingson_core::monthly_review::{self, MonthDayData, MonthlyReviewData, ProjectPulse}; | |
| 31 | - | use goingson_core::weekly_review::ProjectHealth; | |
| 30 | + | use goingson_core::monthly_review::{self, MonthlyReviewData, ProjectPulse}; | |
| 32 | 31 | use goingson_core::{MonthlyGoal, MonthlyGoalStatus, Task}; | |
| 33 | - | use quasi_router::screen::{Act, Field, Figure, Row, Tag}; | |
| 34 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 32 | + | use makeover_layout::Tone; | |
| 33 | + | use quasi_declare::declare; | |
| 34 | + | use quasi_router::screen::{Figure, Tag}; | |
| 35 | + | use quasi_router::{Action, Response, RouteError, Router}; | |
| 35 | 36 | ||
| 36 | 37 | use crate::commands::gather_monthly_review; | |
| 37 | 38 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -104,172 +105,36 @@ | |||
| 104 | 105 | } | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | - | /// One task, the way every list on this screen writes one. | |
| 108 | - | /// | |
| 109 | - | /// The project is `meta` rather than a token, for the reason the weekly review | |
| 110 | - | /// gives: a plain trailing fact with no tone of its own and no click to answer. | |
| 111 | - | fn task_row(task: &Task) -> Row { | |
| 112 | - | let row = Row::new(&task.title); | |
| 113 | - | match &task.project_name { | |
| 114 | - | Some(project) => row.meta(project), | |
| 115 | - | None => row, | |
| 108 | + | declare! { | |
| 109 | + | /// One task, the way every list on this screen writes one. | |
| 110 | + | /// | |
| 111 | + | /// The project is `meta` rather than a token, for the reason the weekly | |
| 112 | + | /// review gives: a plain trailing fact with no tone of its own and no click | |
| 113 | + | /// to answer. | |
| 114 | + | shape task_row(task: &Task) -> Row; | |
| 115 | + | ||
| 116 | + | row &task.title { | |
| 117 | + | for project in task.project_name.iter() { | |
| 118 | + | meta project; | |
| 119 | + | } | |
| 116 | 120 | } | |
| 117 | 121 | } | |
| 118 | 122 | ||
| 119 | - | /// The month at a glance. | |
| 120 | - | /// | |
| 121 | - | /// **`intensity` is a renderer's encoding of a number, and the description | |
| 122 | - | /// carries the number.** `MonthDayData` carries both `completed_count` and | |
| 123 | - | /// `intensity`, a 0-3 bucket. A shade is one renderer's way of saying "a lot", | |
| 124 | - | /// it runs out of room at 3, and a host with room to print `12` should print | |
| 125 | - | /// `12`. So the days below carry counts and `intensity` is not described. | |
| 126 | - | /// | |
| 127 | - | /// # The grid is not described | |
| 128 | - | /// | |
| 129 | - | /// A month grid is [`RegionKind::Ceded`]'s shape. A list of days that had | |
| 130 | - | /// something on them keeps every fact the grid carries except the shape, and | |
| 131 | - | /// inventing a `Node::Calendar` to keep the shape is a vocabulary decision this | |
| 132 | - | /// screen has no standing to make alone. | |
| 133 | - | /// | |
| 134 | - | /// Empty days are left out rather than listed as zeroes. Thirty-one rows of | |
| 135 | - | /// which twenty say nothing is a worse reading of the month than eleven that | |
| 136 | - | /// do, and the totals underneath already say how much of the month was quiet. | |
| 137 | - | fn heat_map(days: &[MonthDayData]) -> Vec<Node> { | |
| 138 | - | let rows: Vec<Row> = days | |
| 139 | - | .iter() | |
| 140 | - | .filter(|day| day.completed_count > 0 || day.event_count > 0 || day.is_vacation) | |
| 141 | - | .map(|day| { | |
| 142 | - | let mut counts = Vec::new(); | |
| 143 | - | if day.completed_count > 0 { | |
| 144 | - | counts.push(format!("{} done", day.completed_count)); | |
| 145 | - | } | |
| 146 | - | if day.event_count > 0 { | |
| 147 | - | counts.push(format!("{} events", day.event_count)); | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | let mut row = Row::new(format!("{}", day.day_number)); | |
| 151 | - | if day.is_today { | |
| 152 | - | row = row.token(Tag::badge("Today").tone(makeover_layout::Tone::Info)); | |
| 153 | - | } | |
| 154 | - | if day.is_vacation { | |
| 155 | - | // A day off is why the counts are absent rather than zero, so | |
| 156 | - | // it is a token and not merely a shade on the cell. | |
| 157 | - | row = row.token(Tag::badge("Day off")); | |
| 158 | - | } | |
| 159 | - | if counts.is_empty() { | |
| 160 | - | row | |
| 161 | - | } else { | |
| 162 | - | row.meta(counts.join(", ")) | |
| 163 | - | } | |
| 164 | - | }) | |
| 165 | - | .collect(); | |
| 166 | - | ||
| 167 | - | if rows.is_empty() { | |
| 168 | - | return vec![ | |
| 169 | - | Node::section("The Month"), | |
| 170 | - | Node::empty("Nothing recorded this month yet."), | |
| 171 | - | ]; | |
| 172 | - | } | |
| 173 | - | vec![Node::section("The Month"), Node::list(rows)] | |
| 123 | + | /// One day the month recorded something on. | |
| 124 | + | struct Marked { | |
| 125 | + | number: u32, | |
| 126 | + | is_today: bool, | |
| 127 | + | is_vacation: bool, | |
| 128 | + | /// What happened on it, or nothing. | |
| 129 | + | counts: String, | |
| 174 | 130 | } | |
| 175 | 131 | ||
| 176 | - | /// What the month added up to. | |
| 177 | - | /// | |
| 178 | - | /// Figures rather than prose, which is what `renderStats` draws and what the | |
| 179 | - | /// numbers are. The busiest and quietest days are dates the core crate has | |
| 180 | - | /// already formatted, and they are absent rather than zero when the month has | |
| 181 | - | /// not produced one: a month with no completions has no busiest day, and | |
| 182 | - | /// "None" would be a different claim. | |
| 183 | - | fn stats(data: &MonthlyReviewData) -> Vec<Node> { | |
| 184 | - | let mut figures = vec![ | |
| 185 | - | Figure::new(data.tasks_completed_count.to_string(), "Tasks Completed"), | |
| 186 | - | Figure::new(data.tasks_created_count.to_string(), "Tasks Created"), | |
| 187 | - | Figure::new(data.events_count.to_string(), "Events"), | |
| 188 | - | Figure::new(data.completion_streak.to_string(), "Longest Streak"), | |
| 189 | - | ]; | |
| 190 | - | if let Some(busiest) = &data.busiest_day { | |
| 191 | - | figures.push(Figure::new(busiest, "Busiest Day")); | |
| 192 | - | } | |
| 193 | - | if let Some(quietest) = &data.quietest_day { | |
| 194 | - | figures.push(Figure::new(quietest, "Quietest Day")); | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | vec![Node::section("The Numbers"), Node::stats(figures)] | |
| 132 | + | /// Whether the day has anything to put in its trailing slot. | |
| 133 | + | fn has_counts(day: &Marked) -> bool { | |
| 134 | + | !day.counts.is_empty() | |
| 198 | 135 | } | |
| 199 | 136 | ||
| 200 | - | /// The tasks the month finished. | |
| 201 | - | /// | |
| 202 | - | /// Core caps this at six for the card; the cap is the data's and not the | |
| 203 | - | /// description's, so nothing is truncated again here. The count above it is the | |
| 204 | - | /// real total, which is what makes the cap readable rather than misleading. | |
| 205 | - | fn accomplished(data: &MonthlyReviewData) -> Vec<Node> { | |
| 206 | - | if data.tasks_completed_top.is_empty() { | |
| 207 | - | return Vec::new(); | |
| 208 | - | } | |
| 209 | - | vec![ | |
| 210 | - | Node::section("Accomplished"), | |
| 211 | - | Node::list(data.tasks_completed_top.iter().map(task_row)), | |
| 212 | - | ] | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | /// Which way each project moved. | |
| 216 | - | /// | |
| 217 | - | /// `direction` is a string core writes ("growing", "shrinking", "stable"). The | |
| 218 | - | /// direction is the fact and an arrow glyph is one renderer's spelling of it, | |
| 219 | - | /// so this says the word and tones it: a project | |
| 220 | - | /// that closed more than it opened is the good case, which no glyph conveys on | |
| 221 | - | /// its own. | |
| 222 | - | fn project_pulse(pulse: &[ProjectPulse]) -> Vec<Node> { | |
| 223 | - | if pulse.is_empty() { | |
| 224 | - | return Vec::new(); | |
| 225 | - | } | |
| 226 | - | let rows = pulse.iter().map(|project| { | |
| 227 | - | let (label, tone) = match project.direction.as_str() { | |
| 228 | - | "shrinking" => ("Shrinking", makeover_layout::Tone::Success), | |
| 229 | - | "growing" => ("Growing", makeover_layout::Tone::Warning), | |
| 230 | - | _ => ("Stable", makeover_layout::Tone::Neutral), | |
| 231 | - | }; | |
| 232 | - | Row::new(&project.name) | |
| 233 | - | .token(Tag::badge(label).tone(tone)) | |
| 234 | - | .meta(format!( | |
| 235 | - | "{} done, {} added", | |
| 236 | - | project.completed, project.created | |
| 237 | - | )) | |
| 238 | - | }); | |
| 239 | - | ||
| 240 | - | vec![Node::section("Project Pulse"), Node::list(rows)] | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | /// How each project is doing, on the same three-value scale the weekly review | |
| 244 | - | /// reads. | |
| 245 | - | fn projects_health(health: &[ProjectHealth]) -> Vec<Node> { | |
| 246 | - | if health.is_empty() { | |
| 247 | - | return Vec::new(); | |
| 248 | - | } | |
| 249 | - | let rows = health.iter().map(|project| { | |
| 250 | - | Row::new(&project.name) | |
| 251 | - | .token(Tag::badge(&project.status).tone(health_tone(&project.status))) | |
| 252 | - | }); | |
| 253 | - | ||
| 254 | - | vec![Node::section("Project Health"), Node::list(rows)] | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | /// What the month said about itself. | |
| 258 | - | /// | |
| 259 | - | /// Core computes these as finished sentences, so there is nothing here to | |
| 260 | - | /// describe beyond saying they are a list of statements rather than a | |
| 261 | - | /// paragraph. | |
| 262 | - | fn patterns(data: &MonthlyReviewData) -> Vec<Node> { | |
| 263 | - | if data.patterns.is_empty() { | |
| 264 | - | return Vec::new(); | |
| 265 | - | } | |
| 266 | - | vec![ | |
| 267 | - | Node::section("Patterns"), | |
| 268 | - | Node::list(data.patterns.iter().map(Row::new)), | |
| 269 | - | ] | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | /// The month's goals, and the empty slots left. | |
| 137 | + | /// One goal, with the move it offers next. | |
| 273 | 138 | /// | |
| 274 | 139 | /// # The second finding | |
| 275 | 140 | /// | |
| @@ -282,173 +147,416 @@ | |||
| 282 | 147 | /// | |
| 283 | 148 | /// The describable half: a button labelled with the *current* status, whose | |
| 284 | 149 | /// effect is a table the user cannot see, says nothing about what pressing it | |
| 285 | - | /// will do. Here each goal offers the move by name — "Mark done", "Give up on | |
| 286 | - | /// it", "Make it active again" — so the label is the outcome. | |
| 150 | + | /// will do. Here each goal offers the move by name -- "Mark done", "Give up on | |
| 151 | + | /// it", "Make it active again" -- so the label is the outcome. | |
| 287 | 152 | /// | |
| 288 | 153 | /// Naming the target also removes a race: computing the next status from a copy | |
| 289 | 154 | /// read at render time lets a second window write a status derived from what it | |
| 290 | 155 | /// saw rather than from what is stored. The route never has to know what the | |
| 291 | 156 | /// goal was before. | |
| 292 | - | fn goals(data: &MonthlyReviewData, month: NaiveDate) -> Vec<Node> { | |
| 293 | - | let mut out = vec![Node::section("Goals")]; | |
| 294 | - | ||
| 295 | - | for goal in &data.goals { | |
| 296 | - | let (label, next) = match goal.status { | |
| 297 | - | MonthlyGoalStatus::Active => ("Mark done", MonthlyGoalStatus::Done), | |
| 298 | - | MonthlyGoalStatus::Done => ("Give up on it", MonthlyGoalStatus::Abandoned), | |
| 299 | - | MonthlyGoalStatus::Abandoned => ("Make it active again", MonthlyGoalStatus::Active), | |
| 300 | - | }; | |
| 301 | - | out.push(Node::list([goal_row(goal, month, label, &next)])); | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | let taken = i32::try_from(data.goals.len()).unwrap_or(GOAL_SLOTS); | |
| 305 | - | if taken < GOAL_SLOTS { | |
| 306 | - | // The JS draws one empty slot per remaining position, each opening the | |
| 307 | - | // same modal. One form is the same offer without pretending the | |
| 308 | - | // positions differ: the next one is the next one. | |
| 309 | - | out.push(Node::Form { | |
| 310 | - | action: in_month(Action::post("/monthly-review/goals"), month), | |
| 311 | - | submit: "Add goal".to_owned(), | |
| 312 | - | fields: vec![{ | |
| 313 | - | let mut field = Field::new(makeover_layout::FieldKind::Text, "text", "Goal"); | |
| 314 | - | field.placeholder = Some("What do you want to achieve this month?".to_owned()); | |
| 315 | - | // The JS marks this `required: true`, so a host that can refuse | |
| 316 | - | // an empty box refuses it before anything is sent. The check in | |
| 317 | - | // `add_goal` is the backstop for a request that did not come | |
| 318 | - | // through the form. | |
| 319 | - | field.required = true; | |
| 320 | - | field | |
| 321 | - | }], | |
| 322 | - | }); | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | out | |
| 157 | + | struct Goal { | |
| 158 | + | stored: MonthlyGoal, | |
| 159 | + | /// What the move is called. | |
| 160 | + | move_label: &'static str, | |
| 161 | + | /// The status that move writes. | |
| 162 | + | next: MonthlyGoalStatus, | |
| 163 | + | /// What the goal is now. | |
| 164 | + | status_label: &'static str, | |
| 165 | + | status_tone: makeover_layout::Tone, | |
| 326 | 166 | } | |
| 327 | 167 | ||
| 328 | - | /// One goal, with the move it offers and the way to drop it. | |
| 329 | - | fn goal_row(goal: &MonthlyGoal, month: NaiveDate, label: &str, next: &MonthlyGoalStatus) -> Row { | |
| 330 | - | let (status_label, tone) = match goal.status { | |
| 331 | - | MonthlyGoalStatus::Active => ("Active", makeover_layout::Tone::Info), | |
| 332 | - | MonthlyGoalStatus::Done => ("Done", makeover_layout::Tone::Success), | |
| 333 | - | MonthlyGoalStatus::Abandoned => ("Abandoned", makeover_layout::Tone::Neutral), | |
| 334 | - | }; | |
| 335 | - | ||
| 336 | - | Row::new(&goal.text) | |
| 337 | - | .token(Tag::badge(status_label).tone(tone)) | |
| 338 | - | .act(Act::new( | |
| 339 | - | label, | |
| 340 | - | in_month( | |
| 341 | - | Action::post(format!("/monthly-review/goals/{}/status", goal.id)) | |
| 342 | - | .with("status", next.as_str()), | |
| 343 | - | month, | |
| 344 | - | ), | |
| 345 | - | )) | |
| 346 | - | .act( | |
| 347 | - | Act::new( | |
| 348 | - | "Delete", | |
| 349 | - | in_month( | |
| 350 | - | Action::post(format!("/monthly-review/goals/{}/delete", goal.id)), | |
| 351 | - | month, | |
| 352 | - | ), | |
| 353 | - | ) | |
| 354 | - | .tone(makeover_layout::Tone::Danger) | |
| 355 | - | .confirm("Are you sure you want to delete this goal?"), | |
| 356 | - | ) | |
| 168 | + | /// Everything the review draws, read once. | |
| 169 | + | struct Review { | |
| 170 | + | data: MonthlyReviewData, | |
| 171 | + | month: NaiveDate, | |
| 172 | + | /// The days that had something on them. | |
| 173 | + | /// | |
| 174 | + | /// Empty days are left out rather than listed as zeroes. Thirty-one rows of | |
| 175 | + | /// which twenty say nothing is a worse reading of the month than eleven | |
| 176 | + | /// that do, and the totals underneath already say how much of the month was | |
| 177 | + | /// quiet. | |
| 178 | + | days: Vec<Marked>, | |
| 179 | + | goals: Vec<Goal>, | |
| 357 | 180 | } | |
| 358 | 181 | ||
| 359 | - | /// The reflection. | |
| 360 | - | /// | |
| 361 | - | /// Two stored columns rather than the weekly review's one blob, so none of that | |
| 362 | - | /// screen's marker-parsing is needed here. The prompts are the JS's, verbatim, | |
| 363 | - | /// including the placeholders: they are the question being asked and not | |
| 364 | - | /// decoration. | |
| 365 | - | /// | |
| 366 | - | /// The draft finding the weekly review filed applies unchanged — this screen's | |
| 367 | - | /// JS keeps unsent keystrokes in `localStorage` too, and [`Field`] still cannot | |
| 368 | - | /// say a value is a draft. Recorded rather than re-filed: it is one gap with | |
| 369 | - | /// two consumers, which is the note quasicoherent already holds. | |
| 370 | - | fn reflection(data: &MonthlyReviewData, month: NaiveDate) -> Vec<Node> { | |
| 371 | - | let (highlight, change) = match &data.reflection { | |
| 372 | - | Some(saved) => (saved.highlight_text.clone(), saved.change_text.clone()), | |
| 373 | - | None => (String::new(), String::new()), | |
| 374 | - | }; | |
| 375 | - | let reviewed = data.reflection.is_some(); | |
| 376 | - | ||
| 377 | - | let field = |name: &str, label: &str, placeholder: &str, value: String| { | |
| 378 | - | let mut field = Field::new(makeover_layout::FieldKind::Textarea, name, label).value(value); | |
| 379 | - | field.placeholder = Some(placeholder.to_owned()); | |
| 380 | - | field | |
| 381 | - | }; | |
| 382 | - | ||
| 383 | - | vec![ | |
| 384 | - | Node::section("Reflection"), | |
| 385 | - | Node::Form { | |
| 386 | - | action: in_month(Action::post("/monthly-review/complete"), month), | |
| 387 | - | submit: if reviewed { | |
| 388 | - | "Save notes".to_owned() | |
| 389 | - | } else { | |
| 390 | - | "Complete review".to_owned() | |
| 391 | - | }, | |
| 392 | - | fields: vec![ | |
| 393 | - | field( | |
| 394 | - | "highlight", | |
| 395 | - | "What was the highlight of this month?", | |
| 396 | - | "Shipped the thing I had been putting off...", | |
| 397 | - | highlight, | |
| 398 | - | ), | |
| 399 | - | field( | |
| 400 | - | "change", | |
| 401 | - | "What would you change?", | |
| 402 | - | "Too many small tasks, not enough deep work...", | |
| 403 | - | change, | |
| 404 | - | ), | |
| 405 | - | ], | |
| 406 | - | }, | |
| 407 | - | ] | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | /// The whole screen. | |
| 411 | - | /// | |
| 412 | - | /// Built here rather than inside each route for the reason the projects screen | |
| 413 | - | /// gives: a write lands in more than one section — completing a goal changes | |
| 414 | - | /// the goal list and the banner above it — and a `Response` names one region. | |
| 415 | - | fn screen(state: &AppState, month: NaiveDate) -> Result<Screen, RouteError> { | |
| 182 | + | /// Read the month. | |
| 183 | + | fn read(state: &AppState, month: NaiveDate) -> Result<Review, RouteError> { | |
| 416 | 184 | let data = load(state, month)?; | |
| 417 | 185 | ||
| 418 | - | let band = Slot::new("month-band", RegionKind::Band) | |
| 419 | - | .with(Node::page(&data.month_display)) | |
| 420 | - | .with(Node::act( | |
| 421 | - | "Previous month", | |
| 422 | - | in_month(Action::get("/monthly-review"), step(month, false)), | |
| 423 | - | )) | |
| 424 | - | .with(Node::act( | |
| 425 | - | "Next month", | |
| 426 | - | in_month(Action::get("/monthly-review"), step(month, true)), | |
| 427 | - | )) | |
| 428 | - | // Bare, with no month on it: this is the one control whose whole job is | |
| 429 | - | // to leave the month it was offered under. | |
| 430 | - | .with(Node::act("This month", Action::get("/monthly-review"))); | |
| 186 | + | let days = data | |
| 187 | + | .days | |
| 188 | + | .iter() | |
| 189 | + | .filter(|day| day.completed_count > 0 || day.event_count > 0 || day.is_vacation) | |
| 190 | + | .map(|day| { | |
| 191 | + | let mut counts = Vec::new(); | |
| 192 | + | if day.completed_count > 0 { | |
| 193 | + | counts.push(format!("{} done", day.completed_count)); | |
| 194 | + | } | |
| 195 | + | if day.event_count > 0 { | |
| 196 | + | counts.push(format!("{} events", day.event_count)); | |
| 197 | + | } | |
| 198 | + | Marked { | |
| 199 | + | number: day.day_number, | |
| 200 | + | is_today: day.is_today, | |
| 201 | + | is_vacation: day.is_vacation, | |
| 202 | + | counts: counts.join(", "), | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | .collect(); | |
| 431 | 206 | ||
| 432 | - | let mut pane = Slot::new("monthly-review", RegionKind::Pane); | |
| 433 | - | if data.reflection.is_some() { | |
| 434 | - | pane = pane.with(Node::banner( | |
| 435 | - | makeover_layout::Tone::Info, | |
| 436 | - | "This month is already reviewed. Your notes stay editable.", | |
| 437 | - | )); | |
| 207 | + | let goals = data | |
| 208 | + | .goals | |
| 209 | + | .iter() | |
| 210 | + | .map(|goal| { | |
| 211 | + | let (move_label, next) = match goal.status { | |
| 212 | + | MonthlyGoalStatus::Active => ("Mark done", MonthlyGoalStatus::Done), | |
| 213 | + | MonthlyGoalStatus::Done => ("Give up on it", MonthlyGoalStatus::Abandoned), | |
| 214 | + | MonthlyGoalStatus::Abandoned => ("Make it active again", MonthlyGoalStatus::Active), | |
| 215 | + | }; | |
| 216 | + | let (status_label, status_tone) = match goal.status { | |
| 217 | + | MonthlyGoalStatus::Active => ("Active", makeover_layout::Tone::Info), | |
| 218 | + | MonthlyGoalStatus::Done => ("Done", makeover_layout::Tone::Success), | |
| 219 | + | MonthlyGoalStatus::Abandoned => ("Abandoned", makeover_layout::Tone::Neutral), | |
| 220 | + | }; | |
| 221 | + | Goal { | |
| 222 | + | stored: goal.clone(), | |
| 223 | + | move_label, | |
| 224 | + | next, | |
| 225 | + | status_label, | |
| 226 | + | status_tone, | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | .collect(); | |
| 230 | + | ||
| 231 | + | Ok(Review { | |
| 232 | + | data, | |
| 233 | + | month, | |
| 234 | + | days, | |
| 235 | + | goals, | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | declare! { | |
| 240 | + | /// The month at a glance. | |
| 241 | + | /// | |
| 242 | + | /// **`intensity` is a renderer's encoding of a number, and the description | |
| 243 | + | /// carries the number.** `MonthDayData` carries both `completed_count` and | |
| 244 | + | /// `intensity`, a 0-3 bucket. A shade is one renderer's way of saying "a | |
| 245 | + | /// lot", it runs out of room at 3, and a host with room to print `12` | |
| 246 | + | /// should print `12`. So the days below carry counts and `intensity` is not | |
| 247 | + | /// described. | |
| 248 | + | /// | |
| 249 | + | /// A day off is why the counts are absent rather than zero, so it is a | |
| 250 | + | /// token and not merely a shade on the cell. | |
| 251 | + | /// | |
| 252 | + | /// # The grid is not described | |
| 253 | + | /// | |
| 254 | + | /// A month grid is [`RegionKind::Ceded`]'s shape. A list of days that had | |
| 255 | + | /// something on them keeps every fact the grid carries except the shape, | |
| 256 | + | /// and inventing a `Node::Calendar` to keep the shape is a vocabulary | |
| 257 | + | /// decision this screen has no standing to make alone. | |
| 258 | + | shape heat_map(review: &Review) -> Vec<Node>; | |
| 259 | + | ||
| 260 | + | section "The Month"; | |
| 261 | + | ||
| 262 | + | empty "Nothing recorded this month yet." when review.days.is_empty(); | |
| 263 | + | ||
| 264 | + | list { | |
| 265 | + | for day in review.days.iter() { | |
| 266 | + | row day.number.to_string() { | |
| 267 | + | token Tag::badge("Today").tone(Tone::Info) when day.is_today; | |
| 268 | + | token Tag::badge("Day off") when day.is_vacation; | |
| 269 | + | meta &day.counts when has_counts(day); | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | } unless review.days.is_empty(); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | declare! { | |
| 276 | + | /// What the month added up to. | |
| 277 | + | /// | |
| 278 | + | /// Figures rather than prose, which is what `renderStats` draws and what | |
| 279 | + | /// the numbers are. The busiest and quietest days are dates the core crate | |
| 280 | + | /// has already formatted, and they are absent rather than zero when the | |
| 281 | + | /// month has not produced one: a month with no completions has no busiest | |
| 282 | + | /// day, and "None" would be a different claim. | |
| 283 | + | shape numbers(review: &Review) -> Vec<Node>; | |
| 284 | + | ||
| 285 | + | section "The Numbers"; | |
| 286 | + | ||
| 287 | + | stats [] { | |
| 288 | + | figure Figure::new(review.data.tasks_completed_count.to_string(), "Tasks Completed"); | |
| 289 | + | figure Figure::new(review.data.tasks_created_count.to_string(), "Tasks Created"); | |
| 290 | + | figure Figure::new(review.data.events_count.to_string(), "Events"); | |
| 291 | + | figure Figure::new(review.data.completion_streak.to_string(), "Longest Streak"); | |
| 292 | + | ||
| 293 | + | for busiest in review.data.busiest_day.iter() { | |
| 294 | + | figure Figure::new(busiest, "Busiest Day"); |
Lines truncated
| @@ -121,12 +121,14 @@ | |||
| 121 | 121 | use std::time::{Duration, SystemTime}; | |
| 122 | 122 | ||
| 123 | 123 | use chrono::TimeZone as _; | |
| 124 | - | use goingson_core::{Task, TaskFilterQuery, TaskId, TaskStatus, TimeSession, TimeSessionMode}; | |
| 125 | - | use makeover_layout::Tone; | |
| 126 | - | use quasi_router::screen::{Act, Consult, Field, Figure, Meter, Row, Tag}; | |
| 127 | - | use quasi_router::{ | |
| 128 | - | Action, Chrome, Node, RegionKind, Response, Role, RouteError, Router, Screen, Slot, | |
| 124 | + | use goingson_core::{ | |
| 125 | + | Task, TaskFilterQuery, TaskId, TaskStatus, TimeReport, TimeReportProject, TimeSession, | |
| 126 | + | TimeSessionMode, TimeSummaryPanel, | |
| 129 | 127 | }; | |
| 128 | + | use makeover_layout::Tone; | |
| 129 | + | use quasi_declare::declare; | |
| 130 | + | use quasi_router::screen::{Consult, Figure, Meter, Tag}; | |
| 131 | + | use quasi_router::{Action, Chrome, Node, Response, Role, RouteError, Router, Slot}; | |
| 130 | 132 | ||
| 131 | 133 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 132 | 134 | ||
| @@ -165,11 +167,14 @@ | |||
| 165 | 167 | chrome.presenting(PANEL, Role::Activity, Node::Region(band())) | |
| 166 | 168 | } | |
| 167 | 169 | ||
| 168 | - | /// The empty band, carrying the address and the call every answer repeats. | |
| 169 | - | fn band() -> Slot { | |
| 170 | - | Slot::new(BODY, RegionKind::Band) | |
| 171 | - | .fed_by(Action::get("/timer/panel")) | |
| 172 | - | .live() | |
| 170 | + | declare! { | |
| 171 | + | /// The empty band, carrying the address and the call every answer repeats. | |
| 172 | + | shape band() -> Slot; | |
| 173 | + | ||
| 174 | + | region BODY as Band { | |
| 175 | + | fed_by Action::get("/timer/panel"); | |
| 176 | + | live; | |
| 177 | + | } | |
| 173 | 178 | } | |
| 174 | 179 | ||
| 175 | 180 | /// When a session started, as the instant a readout counts from. | |
| @@ -182,11 +187,6 @@ | |||
| 182 | 187 | } | |
| 183 | 188 | ||
| 184 | 189 | /// The instant a countdown runs to, if this session is one. | |
| 185 | - | /// | |
| 186 | - | /// `None` for a Track session, which has no end to count to, and for a Focus | |
| 187 | - | /// session written before migration 068 gave the column somewhere to live. Both | |
| 188 | - | /// answer the same way here, because a countdown with no end is a countdown a | |
| 189 | - | /// renderer cannot draw and the band says the elapsed time instead. | |
| 190 | 190 | fn ending(session: &TimeSession) -> Option<SystemTime> { | |
| 191 | 191 | instant(session.ends_at?) | |
| 192 | 192 | } | |
| @@ -215,64 +215,123 @@ | |||
| 215 | 215 | } | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | - | /// What the panel holds: nothing, or the running timer. | |
| 219 | - | /// | |
| 220 | - | /// The `fed_by` and `live` of [`band`] are repeated here on purpose. See the | |
| 221 | - | /// module header: the answer replaces the element, so an answer that dropped | |
| 222 | - | /// them would be a panel that stopped asking. | |
| 223 | - | fn contents(state: &AppState) -> Result<Slot, RouteError> { | |
| 224 | - | let running = state | |
| 218 | + | /// What is being timed, if anything. | |
| 219 | + | struct Running { | |
| 220 | + | session: TimeSession, | |
| 221 | + | /// The task the session is against, as the store reads it out. | |
| 222 | + | description: String, | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Read what is running. | |
| 226 | + | fn running(state: &AppState) -> Result<Option<Running>, RouteError> { | |
| 227 | + | Ok(state | |
| 225 | 228 | .tasks | |
| 226 | 229 | .get_active_timer(DESKTOP_USER_ID) | |
| 227 | - | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 230 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 231 | + | .map(|(session, description)| Running { | |
| 232 | + | session, | |
| 233 | + | description, | |
| 234 | + | })) | |
| 235 | + | } | |
| 228 | 236 | ||
| 229 | - | let Some((session, description)) = running else { | |
| 230 | - | return Ok(band()); | |
| 231 | - | }; | |
| 237 | + | /// Whether a timer is running at all. | |
| 238 | + | fn is_running(running: Option<&Running>) -> bool { | |
| 239 | + | running.is_some() | |
| 240 | + | } | |
| 232 | 241 | ||
| 233 | - | // What the widget's `MODE_LABELS` said, off the session rather than out of | |
| 234 | - | // a variable in the renderer's process. Migration 068 is what makes this | |
| 235 | - | // sayable: before it, a focus session found running after a reload read as | |
| 236 | - | // Tracking because nothing in the store told the two apart. | |
| 237 | - | let mut slot = band() | |
| 238 | - | .with(Node::text(session.mode.label())) | |
| 239 | - | .with(Node::text(description)); | |
| 242 | + | /// What the band calls the mode, off the session rather than out of a variable | |
| 243 | + | /// in the renderer's process. | |
| 244 | + | /// | |
| 245 | + | /// Migration 068 is what makes this sayable: before it, a focus session found | |
| 246 | + | /// running after a reload read as Tracking because nothing in the store told | |
| 247 | + | /// the two apart. | |
| 248 | + | fn mode_label(running: Option<&Running>) -> &'static str { | |
| 249 | + | running.map_or("", |running| running.session.mode.label()) | |
| 250 | + | } | |
| 240 | 251 | ||
| 241 | - | // The whole of the app's half of the readout. A focus session counts down | |
| 242 | - | // to the instant it was started for and a tracked one counts up from its | |
| 243 | - | // start, which is the one difference between the two bands. Both are an | |
| 244 | - | // instant the renderer ticks; neither is a number this route computed. See | |
| 245 | - | // the module header. | |
| 246 | - | // | |
| 247 | - | // `started` and `ending` are `None` only for a stamp before the epoch, and | |
| 248 | - | // `ending` also for a focus session predating the column. The band then | |
| 249 | - | // says the task and no time rather than a zero that would read as a timer | |
| 250 | - | // that has just started. | |
| 251 | - | let clock = match session.mode { | |
| 252 | - | TimeSessionMode::Focus => ending(&session).map(|at| Node::Until { at }), | |
| 252 | + | /// The task being timed. | |
| 253 | + | fn description(running: Option<&Running>) -> &str { | |
| 254 | + | running.map_or("", |running| running.description.as_str()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// The instant a countdown runs to, when the band has one to count to. | |
| 258 | + | /// | |
| 259 | + | /// `None` for a Track session, which has no end, and for a Focus session | |
| 260 | + | /// written before migration 068 gave the column somewhere to live. Both answer | |
| 261 | + | /// the same way, because a countdown with no end is a countdown a renderer | |
| 262 | + | /// cannot draw and the band says the elapsed time instead. | |
| 263 | + | fn counting_down(running: Option<&Running>) -> Option<SystemTime> { | |
| 264 | + | let running = running?; | |
| 265 | + | match running.session.mode { | |
| 266 | + | TimeSessionMode::Focus => ending(&running.session), | |
| 253 | 267 | TimeSessionMode::Track => None, | |
| 254 | - | }; | |
| 255 | - | if let Some(node) = clock.or_else(|| started(&session).map(|at| Node::Since { at })) { | |
| 256 | - | slot = slot.with(node); | |
| 257 | 268 | } | |
| 269 | + | } | |
| 258 | 270 | ||
| 259 | - | Ok(slot | |
| 260 | - | .with(Node::Act( | |
| 261 | - | Act::new("Stop", Action::post("/timer/stop")).tone(Tone::Success), | |
| 262 | - | )) | |
| 263 | - | // Discarding throws away time that has already been spent, and unlike | |
| 264 | - | // the widget's ghost button there is no undo behind it, so it confirms. | |
| 265 | - | // The same trade the task list's Delete makes. | |
| 266 | - | .with(Node::Act( | |
| 267 | - | Act::new("Discard", Action::post("/timer/discard")) | |
| 268 | - | .tone(Tone::Danger) | |
| 269 | - | .confirm("Discard the time this timer has tracked?"), | |
| 270 | - | ))) | |
| 271 | + | /// When the running session started, as the instant a readout counts from. | |
| 272 | + | fn started_at(running: Option<&Running>) -> Option<SystemTime> { | |
| 273 | + | started(&running?.session) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// The instant the band counts up from, when it is not counting down. | |
| 277 | + | /// | |
| 278 | + | /// The whole of the app's half of the readout: a focus session counts down to | |
| 279 | + | /// the instant it was started for and a tracked one counts up from its start, | |
| 280 | + | /// which is the one difference between the two bands. Both are an instant the | |
| 281 | + | /// renderer ticks; neither is a number a route computed. See the module header. | |
| 282 | + | /// | |
| 283 | + | /// Both are absent for a stamp before the epoch, and the countdown also for a | |
| 284 | + | /// focus session predating the column. The band then says the task and no time | |
| 285 | + | /// rather than a zero that would read as a timer that has just started. | |
| 286 | + | fn counting_up(running: Option<&Running>) -> Option<SystemTime> { | |
| 287 | + | counting_down(running) | |
| 288 | + | .is_none() | |
| 289 | + | .then(|| started_at(running))? | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | declare! { | |
| 293 | + | /// What the panel holds: nothing, or the running timer. | |
| 294 | + | /// | |
| 295 | + | /// The `fed_by` and `live` of [`band`] are repeated here on purpose. See | |
| 296 | + | /// the module header: the answer replaces the element, so an answer that | |
| 297 | + | /// dropped them would be a panel that stopped asking. | |
| 298 | + | /// | |
| 299 | + | /// Discarding throws away time that has already been spent, and unlike the | |
| 300 | + | /// widget's ghost button there is no undo behind it, so it confirms. The | |
| 301 | + | /// same trade the task list's Delete makes. | |
| 302 | + | shape contents(running: Option<&Running>) -> Slot; | |
| 303 | + | ||
| 304 | + | region BODY as Band { | |
| 305 | + | fed_by Action::get("/timer/panel"); | |
| 306 | + | live; | |
| 307 | + | ||
| 308 | + | text mode_label(running) when is_running(running); | |
| 309 | + | text description(running) when is_running(running); | |
| 310 | + | ||
| 311 | + | for at in counting_down(running).into_iter() { | |
| 312 | + | until at; | |
| 313 | + | } | |
| 314 | + | for at in counting_up(running).into_iter() { | |
| 315 | + | since at; | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | act "Stop" to post "/timer/stop" when is_running(running) { | |
| 319 | + | tone Success; | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | act "Discard" to post "/timer/discard" when is_running(running) { | |
| 323 | + | tone Danger; | |
| 324 | + | confirm "Discard the time this timer has tracked?"; | |
| 325 | + | } | |
| 326 | + | } | |
| 271 | 327 | } | |
| 272 | 328 | ||
| 273 | 329 | /// The panel's contents, as an answer. | |
| 274 | 330 | fn panel(state: &AppState) -> Result<Response, RouteError> { | |
| 275 | - | Ok(Response::fragment(BODY, Node::Region(contents(state)?))) | |
| 331 | + | Ok(Response::fragment( | |
| 332 | + | BODY, | |
| 333 | + | Node::Region(contents(running(state)?.as_ref())), | |
| 334 | + | )) | |
| 276 | 335 | } | |
| 277 | 336 | ||
| 278 | 337 | /// What is running, if anything. | |
| @@ -454,89 +513,84 @@ | |||
| 454 | 513 | } | |
| 455 | 514 | } | |
| 456 | 515 | ||
| 457 | - | /// A number inside bounds that are a rule rather than a track. | |
| 458 | - | /// | |
| 459 | - | /// [`Field::range`]'s cousin and deliberately not it: these are typed, and a | |
| 460 | - | /// value outside the bounds is a thing to be told about rather than a place the | |
| 461 | - | /// control cannot reach. `makeover_layout::FieldKind::Range`'s own docs name | |
| 462 | - | /// this exact case. | |
| 463 | - | fn bounded(name: &'static str, label: &str, value: i64, low: i64, high: i64) -> Field { | |
| 464 | - | Field { | |
| 465 | - | min: Some(low.to_string()), | |
| 466 | - | max: Some(high.to_string()), | |
| 467 | - | value: Some(value.to_string()), | |
| 468 | - | ..Field::new(makeover_layout::FieldKind::Number, name, label) | |
| 516 | + | declare! { | |
| 517 | + | /// The two features, named, and the split a focus session would run to. | |
| 518 | + | /// | |
| 519 | + | /// The cards are the JS's own two paragraphs. They are here for the reason | |
| 520 | + | /// it gives: both features write the same session, so without saying so the | |
| 521 | + | /// two controls read as two words for one button. | |
| 522 | + | /// | |
| 523 | + | /// The two numbers are bounds that are a rule rather than a track, which is | |
| 524 | + | /// why they are typed rather than [`Field::range`]: a value outside them is | |
| 525 | + | /// a thing to be told about rather than a place the control cannot reach. | |
| 526 | + | /// `makeover_layout::FieldKind::Range`'s own docs name this exact case. | |
| 527 | + | /// | |
| 528 | + | /// Each consults the screen carrying every part of the view except its own, | |
| 529 | + | /// because a field sends its value under its own name and an address | |
| 530 | + | /// carrying it too would answer with the value the user just replaced. | |
| 531 | + | shape modes(view: View) -> Slot; | |
| 532 | + | ||
| 533 | + | region "timer-modes" as Group { | |
| 534 | + | section "Track"; | |
| 535 | + | text "An open-ended stopwatch. Runs until you stop it, and records the time \ | |
| 536 | + | against the task."; | |
| 537 | + | ||
| 538 | + | section "Focus"; | |
| 539 | + | text "A countdown of {view.work} minutes, then a {view.rest} minute break. \ | |
| 540 | + | Records the same time."; | |
| 541 | + | ||
| 542 | + | field Number "work" "Minutes of work" { | |
| 543 | + | within "1" "240"; | |
| 544 | + | value view.work.to_string(); | |
| 545 | + | consulting Consult::new( | |
| 546 | + | Action::get("/timer") | |
| 547 | + | .carrying("break", view.rest.to_string()) | |
| 548 | + | .carrying("days", view.days.to_string()) | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | field Number "break" "Minutes of break" { | |
| 553 | + | within "1" "60"; | |
| 554 | + | value view.rest.to_string(); | |
| 555 | + | consulting Consult::new( | |
| 556 | + | Action::get("/timer") | |
| 557 | + | .carrying("work", view.work.to_string()) | |
| 558 | + | .carrying("days", view.days.to_string()) | |
| 559 | + | ); | |
| 560 | + | } | |
| 469 | 561 | } | |
| 470 | 562 | } | |
| 471 | 563 | ||
| 472 | - | /// The two features, named, and the split a focus session would run to. | |
| 473 | - | /// | |
| 474 | - | /// The cards are the JS's own two paragraphs. They are here for the reason it | |
| 475 | - | /// gives: both features write the same session, so without saying so the two | |
| 476 | - | /// controls read as two words for one button. | |
| 477 | - | /// | |
| 478 | - | /// The split's fields carry every part of the view except their own, because a | |
| 479 | - | /// field sends its value under its own name and an address carrying it too | |
| 480 | - | /// would be answering with the value the user just replaced. | |
| 481 | - | fn modes(view: View) -> Slot { | |
| 482 | - | let work = bounded("work", "Minutes of work", view.work, 1, 240).consulting(Consult::new( | |
| 483 | - | Action::get("/timer") | |
| 484 | - | .carrying("break", view.rest.to_string()) | |
| 485 | - | .carrying("days", view.days.to_string()), | |
| 486 | - | )); | |
| 487 | - | let rest = bounded("break", "Minutes of break", view.rest, 1, 60).consulting(Consult::new( | |
| 488 | - | Action::get("/timer") | |
| 489 | - | .carrying("work", view.work.to_string()) | |
| 490 | - | .carrying("days", view.days.to_string()), | |
| 491 | - | )); | |
| 564 | + | declare! { | |
| 565 | + | /// What is running, as the screen's own band. | |
| 566 | + | /// | |
| 567 | + | /// The panel says the same thing at the bottom of every screen, and this is | |
| 568 | + | /// not that region answered twice: the two are separate elements with | |
| 569 | + | /// separate addresses, and this one's Stop answers the screen while the | |
| 570 | + | /// panel's answers the panel. The panel catches up on its own cadence, | |
| 571 | + | /// which is what [`Slot::live`] is for. | |
| 572 | + | shape session(running: Option<&Running>, view: View) -> Slot; | |
| 492 | 573 | ||
| 493 | - | Slot::group("timer-modes") | |
| 494 | - | .with(Node::section("Track")) | |
| 495 | - | .with(Node::text( | |
| 496 | - | "An open-ended stopwatch. Runs until you stop it, and records the time \ | |
| 497 | - | against the task.", | |
| 498 | - | )) | |
| 499 | - | .with(Node::section("Focus")) | |
| 500 | - | .with(Node::text(format!( | |
| 501 | - | "A countdown of {} minutes, then a {} minute break. Records the same time.", | |
| 502 | - | view.work, view.rest | |
| 503 | - | ))) | |
| 504 | - | .with(Node::field(work)) | |
| 505 | - | .with(Node::field(rest)) | |
| 506 | - | } | |
| 574 | + | region SESSION as Band { | |
| 575 | + | empty "Nothing is being tracked." unless is_running(running); | |
| 507 | 576 | ||
| 508 | - | /// What is running, as the screen's own band. | |
| 509 | - | /// | |
| 510 | - | /// The panel says the same thing at the bottom of every screen, and this is not | |
| 511 | - | /// that region answered twice: the two are separate elements with separate | |
| 512 | - | /// addresses, and this one's Stop answers the screen while the panel's answers | |
| 513 | - | /// the panel. The panel catches up on its own cadence, which is what | |
| 514 | - | /// [`Slot::live`] is for. | |
| 515 | - | fn session(state: &AppState, view: View) -> Result<Slot, RouteError> { | |
| 516 | - | let running = state | |
| 517 | - | .tasks | |
| 518 | - | .get_active_timer(DESKTOP_USER_ID) | |
| 519 | - | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 577 | + | text description(running) when is_running(running); | |
| 520 | 578 | ||
| 521 | - | let slot = Slot::new(SESSION, RegionKind::Band); | |
| 522 | - | let Some((session, description)) = running else { | |
| 523 | - | return Ok(slot.with(Node::empty("Nothing is being tracked."))); | |
| 524 | - | }; | |
| 579 | + | for at in started_at(running).into_iter() { | |
| 580 | + | since at; | |
| 581 | + | } | |
| 525 | 582 | ||
| 526 | - | let mut slot = slot.with(Node::text(description)); | |
| 527 | - | if let Some(at) = started(&session) { | |
| 528 | - | slot = slot.with(Node::Since { at }); | |
| 583 | + | act "Stop" to doing view.carry(Action::post("/timer/view/stop")) | |
| 584 | + | when is_running(running) { | |
| 585 | + | tone Success; | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | act "Discard" to doing view.carry(Action::post("/timer/view/discard")) | |
| 589 | + | when is_running(running) { | |
| 590 | + | tone Danger; | |
| 591 | + | confirm "Discard the time this timer has tracked?"; | |
| 592 | + | } | |
| 529 | 593 | } | |
| 530 | - | ||
| 531 | - | Ok(slot | |
| 532 | - | .with(Node::Act( | |
| 533 | - | Act::new("Stop", view.carry(Action::post("/timer/view/stop"))).tone(Tone::Success), | |
| 534 | - | )) | |
| 535 | - | .with(Node::Act( | |
| 536 | - | Act::new("Discard", view.carry(Action::post("/timer/view/discard"))) | |
| 537 | - | .tone(Tone::Danger) | |
| 538 | - | .confirm("Discard the time this timer has tracked?"), | |
| 539 | - | ))) | |
| 540 | 594 | } | |
| 541 | 595 | ||
| 542 | 596 | /// The tasks a timer can be started on, most likely first. | |
| @@ -570,227 +624,359 @@ | |||
| 570 | 624 | Ok(out) | |
| 571 | 625 | } | |
| 572 | 626 | ||
| 573 | - | /// One task, with what can be done to it. | |
| 574 | - | /// | |
| 575 | - | /// # What Focus carries that Track does not | |
| 576 | - | /// | |
| 577 | - | /// The split on the address, spent at the moment it is pressed. The shipped row | |
| 578 | - | /// puts the same numbers in the button's `title`; here they are in the label, | |
| 579 | - | /// because a description has no hint to put them in and a control that does not | |
| 580 | - | /// say what it will do is worse than a long label. | |
| 581 | - | /// | |
| 582 | - | /// The row offered Track alone until migration 068. What was missing was not | |
| 583 | - | /// anything the row could say: the session did not record which feature started | |
| 584 | - | /// it, so the two controls would have written the same row and the only | |
| 585 | - | /// difference between them would have been the sentence in the toast. | |
| 586 | - | fn row_for(task: &Task, view: View, busy: bool) -> Row { | |
| 587 | - | let mut row = Row::new(&task.title).meta(task.project_name_or_dash().to_owned()); | |
| 588 | - | ||
| 589 | - | if let Some(estimate) = task.estimated_minutes { | |
| 590 | - | row = row.meta(format!("{} est", spans(estimate))); | |
| 591 | - | } | |
| 592 | - | if task.actual_minutes > 0 { | |
| 593 | - | row = row.meta(format!("{} tracked", spans(task.actual_minutes))); | |
| 594 | - | } | |
| 595 | - | if let Some(marker) = super::Availability::of(task).marker() { | |
| 596 | - | row = row.token(marker); | |
| 597 | - | } | |
| 598 | - | ||
| 599 | - | // Disabled rather than absent while something else is running: the store | |
| 600 | - | // allows one timer per user, so the control is real and momentarily | |
| 601 | - | // refused, and a row that lost its buttons would read as a task that cannot | |
| 602 | - | // be tracked at all. | |
| 603 | - | let mut track = Act::new( | |
| 604 | - | "Track", | |
| 605 | - | view.carry(Action::post("/timer/view/track")) | |
| 606 | - | .with("task", task.id.to_string()), | |
| 607 | - | ) | |
| 608 | - | .tone(Tone::Success); | |
| 609 | - | if busy { | |
| 610 | - | track = track.disabled(); | |
| 611 | - | } | |
| 612 | - | ||
| 613 | - | let mut focus = Act::new( | |
| 614 | - | format!("Focus {}m", view.work), | |
| 615 | - | view.carry(Action::post("/timer/view/focus")) | |
| 616 | - | .with("task", task.id.to_string()), | |
| 617 | - | ); | |
| 618 | - | if busy { | |
| 619 | - | focus = focus.disabled(); | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | // The log-time modal, as the control that opens it. See the module header | |
| 623 | - | // for why it is not a modal here. | |
| 624 | - | let log = Act::new( | |
| 625 | - | "Log", | |
| 626 | - | view.carry(Action::post("/timer/view/log")) | |
| 627 | - | .with("task", task.id.to_string()), | |
| 628 | - | ) | |
| 629 | - | .asking(bounded("minutes", "Minutes", 30, 1, 1440).required()) | |
| 630 | - | .asking( | |
| 631 | - | Field::new(makeover_layout::FieldKind::Date, "date", "Date") | |
| 632 | - | .value(chrono::Local::now().date_naive().to_string()), | |
| 633 | - | ); | |
| 634 | - | ||
| 635 | - | row.act(track).act(focus).act(log) | |
| 627 | + | /// What the Timer screen offers to track, and whether anything is in the way. | |
| 628 | + | struct Offered { | |
| 629 | + | tasks: Vec<Task>, | |
| 630 | + | /// Whether a timer is already running, which is what disables every control | |
| 631 | + | /// below. | |
| 632 | + | busy: bool, | |
| 636 | 633 | } | |
| 637 | 634 | ||
| 638 | - | /// What a timer can be started on, as a region. | |
| 639 | - | fn choices(state: &AppState, view: View) -> Result<Slot, RouteError> { | |
| 640 | - | let running = state | |
| 641 | - | .tasks | |
| 642 | - | .get_active_timer(DESKTOP_USER_ID) | |
| 643 | - | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 644 | - | .map(|(session, _)| session.task_id); | |
| 645 | - | ||
| 646 | - | let tasks = offered(state, running)?; | |
| 647 | - | let slot = Slot::new(CHOICES, RegionKind::Pane); | |
| 648 | - | ||
| 649 | - | if tasks.is_empty() { | |
| 650 | - | return Ok(slot.with(Node::empty("No pending or started tasks to track."))); | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | Ok(slot.with(Node::list( | |
| 654 | - | tasks | |
| 655 | - | .iter() | |
| 656 | - | .map(|task| row_for(task, view, running.is_some())), | |
| 657 | - | ))) | |
| 635 | + | /// Read what can be tracked. | |
| 636 | + | fn offering(state: &AppState, running: Option<&Running>) -> Result<Offered, RouteError> { | |
| 637 | + | let held = running.map(|running| running.session.task_id); | |
| 638 | + | Ok(Offered { | |
| 639 | + | tasks: offered(state, held)?, | |
| 640 | + | busy: held.is_some(), | |
| 641 | + | }) | |
| 658 | 642 | } | |
| 659 | 643 | ||
| 660 | - | /// Where the time went: tracked per project in the window, beside estimated | |
| 661 | - | /// against actual for the same projects. | |
| 662 | - | /// | |
| 663 | - | /// The bar is a [`Meter`] of the project's minutes over everything tracked in | |
| 664 | - | /// the window. Finding 4 in the module header for why it is not the store's | |
| 665 | - | /// `bar_percent`. | |
| 666 | - | fn report(state: &AppState, view: View) -> Result<Slot, RouteError> { | |
| 644 | + | /// Whether the task says how long it should take. | |
| 645 | + | fn has_estimate(task: &Task) -> bool { | |
| 646 | + | task.estimated_minutes.is_some() | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | /// That estimate, in words. | |
| 650 | + | fn estimate_words(task: &Task) -> String { | |
| 651 | + | spans(task.estimated_minutes.unwrap_or(0)) | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | /// Whether any time has been recorded against the task. | |
| 655 | + | fn has_tracked(task: &Task) -> bool { | |
| 656 | + | task.actual_minutes > 0 |
Lines truncated