max / goingson
5 files changed,
+793 insertions,
-1052 deletions
| @@ -4745,7 +4745,7 @@ | |||
| 4745 | 4745 | ||
| 4746 | 4746 | [[package]] | |
| 4747 | 4747 | name = "quasi-router" | |
| 4748 | - | version = "0.101.12" | |
| 4748 | + | version = "0.101.13" | |
| 4749 | 4749 | dependencies = [ | |
| 4750 | 4750 | "makeover-layout", | |
| 4751 | 4751 | ] |
| @@ -68,8 +68,10 @@ | |||
| 68 | 68 | ||
| 69 | 69 | use chrono::{Local, NaiveDateTime, Utc}; | |
| 70 | 70 | use goingson_core::{BlockType, DbValue, Event, EventId, Recurrence, TzKind, parse_natural_date}; | |
| 71 | - | use quasi_router::screen::{Choice, Field, Repeat, Row, Tag}; | |
| 72 | - | use quasi_router::{Action, Node, RegionKind, Response, Reveal, RouteError, Router, Screen, Slot}; | |
| 71 | + | use makeover_layout::Tone; | |
| 72 | + | use quasi_declare::declare; | |
| 73 | + | use quasi_router::screen::{Choice, Repeat, Tag}; | |
| 74 | + | use quasi_router::{Action, Node, Response, Reveal, RouteError, Router}; | |
| 73 | 75 | ||
| 74 | 76 | use crate::commands::event::{EventInput, create_event_now, update_event_now}; | |
| 75 | 77 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -77,114 +79,48 @@ | |||
| 77 | 79 | #[cfg(test)] | |
| 78 | 80 | mod tests; | |
| 79 | 81 | ||
| 80 | - | /// One event, as a row in one of the three sections. | |
| 82 | + | /// What a row leads with. | |
| 81 | 83 | /// | |
| 82 | - | /// The shipped table has five columns (date, time, title, location, actions) | |
| 83 | - | /// and this is the same five facts in a row's slots. The kebab menu is not one | |
| 84 | - | /// of them: `contextMenus.showEvent` offers open, edit, snooze and delete, and | |
| 85 | - | /// three of those four have nowhere to go yet (see the module header), so the | |
| 86 | - | /// row carries the one that does rather than a menu that is mostly disabled. | |
| 87 | - | fn row_for(event: &Event, recurring: bool) -> Row { | |
| 88 | - | // A template's date cell is its pattern, not a start date. `events.js` | |
| 89 | - | // swaps the same cell for the same reason: the arbitrary date a weekly rule | |
| 90 | - | // happens to start on tells the reader nothing about the rule. | |
| 91 | - | let lead = if recurring { | |
| 84 | + | /// A template's date cell is its pattern, not a start date. `events.js` swaps | |
| 85 | + | /// the same cell for the same reason: the arbitrary date a weekly rule happens | |
| 86 | + | /// to start on tells the reader nothing about the rule. | |
| 87 | + | fn lead(event: &Event, recurring: bool) -> String { | |
| 88 | + | if recurring { | |
| 92 | 89 | event.effective_recurrence_rule().map_or_else( | |
| 93 | 90 | || event.recurrence.as_str().to_owned(), | |
| 94 | 91 | |rule| rule.display(), | |
| 95 | 92 | ) | |
| 96 | 93 | } else { | |
| 97 | 94 | event.date_formatted() | |
| 98 | - | }; | |
| 99 | - | ||
| 100 | - | let mut row = Row::new(&event.title) | |
| 101 | - | .secondary(lead) | |
| 102 | - | .meta(event.time_formatted()); | |
| 103 | - | ||
| 104 | - | if event.has_location() { | |
| 105 | - | row = row.token(Tag::badge(event.location_or_empty())); | |
| 106 | 95 | } | |
| 107 | - | if event.has_project() { | |
| 108 | - | row = row.token(Tag::badge(event.project_name_or_empty())); | |
| 109 | - | } | |
| 110 | - | if event.is_snoozed() { | |
| 111 | - | row = row.token(Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning)); | |
| 112 | - | } | |
| 113 | - | if event.is_linked_to_task() { | |
| 114 | - | // A time block rather than an appointment. The JS draws no marker for | |
| 115 | - | // this and the row reads as an ordinary event; the fact is in the data | |
| 116 | - | // and worth one token, since deleting a block and deleting a meeting | |
| 117 | - | // are different acts. | |
| 118 | - | row = row.token(Tag::badge("Time block").tone(makeover_layout::Tone::Info)); | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | row.activate = Some(Action::get(format!("/events/{}", event.id))); | |
| 122 | - | row | |
| 123 | 96 | } | |
| 124 | 97 | ||
| 125 | - | /// The three sections, in the order the screen draws them. | |
| 126 | - | /// | |
| 127 | - | /// Recurring first, which is `events.js`'s own order and not alphabetical | |
| 128 | - | /// accident: the rules are what a reader scans for, and they are the shortest | |
| 129 | - | /// list. | |
| 130 | - | fn sections(state: &AppState, snoozed: bool) -> Result<Vec<Node>, RouteError> { | |
| 131 | - | let mut events = state | |
| 132 | - | .events | |
| 133 | - | .list_all(DESKTOP_USER_ID) | |
| 134 | - | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 98 | + | declare! { | |
| 99 | + | /// One event, as a row in one of the three sections. | |
| 100 | + | /// | |
| 101 | + | /// The shipped table has five columns (date, time, title, location, | |
| 102 | + | /// actions) and this is the same five facts in a row's slots. The kebab menu | |
| 103 | + | /// is not one of them: `contextMenus.showEvent` offers open, edit, snooze | |
| 104 | + | /// and delete, and three of those four have nowhere to go yet (see the | |
| 105 | + | /// module header), so the row carries the one that does rather than a menu | |
| 106 | + | /// that is mostly disabled. | |
| 107 | + | /// | |
| 108 | + | /// A time block is marked as one. The JS draws no marker and the row reads | |
| 109 | + | /// as an ordinary event; the fact is in the data and worth one token, since | |
| 110 | + | /// deleting a block and deleting a meeting are different acts. | |
| 111 | + | shape row_for(event: &Event, recurring: bool) -> Row; | |
| 135 | 112 | ||
| 136 | - | // The snooze filter is applied here rather than read off a narrower query, | |
| 137 | - | // and finding out which was the port's one real correction. `events.js` | |
| 138 | - | // calls the `list_events` COMMAND, which excludes snoozed rows and offers | |
| 139 | - | // `list_snoozed_events` to merge them back in. The repository's `list_all` | |
| 140 | - | // is not that query: it filters only events converted into contexts, so a | |
| 141 | - | // described screen reading it directly would have shown snoozed events | |
| 142 | - | // always, which is not what the shipped screen does. | |
| 143 | - | // | |
| 144 | - | // Filtering here rather than adding a repository read keeps the two | |
| 145 | - | // versions of "what is on the calendar" in one place, and there is no | |
| 146 | - | // second list to de-duplicate against. | |
| 147 | - | if !snoozed { | |
| 148 | - | events.retain(|event| !event.is_snoozed()); | |
| 113 | + | row &event.title { | |
| 114 | + | secondary lead(event, recurring); | |
| 115 | + | meta event.time_formatted(); | |
| 116 | + | ||
| 117 | + | token Tag::badge(event.location_or_empty()) when event.has_location(); | |
| 118 | + | token Tag::badge(event.project_name_or_empty()) when event.has_project(); | |
| 119 | + | token Tag::badge("Snoozed").tone(Tone::Warning) when event.is_snoozed(); | |
| 120 | + | token Tag::badge("Time block").tone(Tone::Info) when event.is_linked_to_task(); | |
| 121 | + | ||
| 122 | + | activate to get "/events/{event.id}"; | |
| 149 | 123 | } | |
| 150 | - | ||
| 151 | - | if events.is_empty() { | |
| 152 | - | return Ok(vec![Node::empty("No events scheduled.")]); | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | let now = Utc::now(); | |
| 156 | - | let (templates, rest): (Vec<&Event>, Vec<&Event>) = | |
| 157 | - | events.iter().partition(|event| is_template(event)); | |
| 158 | - | let (past, upcoming): (Vec<&Event>, Vec<&Event>) = | |
| 159 | - | rest.iter().partition(|event| event.start_time < now); | |
| 160 | - | ||
| 161 | - | let mut out = Vec::new(); | |
| 162 | - | ||
| 163 | - | if !templates.is_empty() { | |
| 164 | - | out.push(Node::section(format!("Recurring ({})", templates.len()))); | |
| 165 | - | out.push(Node::list( | |
| 166 | - | templates.iter().map(|event| row_for(event, true)), | |
| 167 | - | )); | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | out.push(Node::section("Upcoming")); | |
| 171 | - | if upcoming.is_empty() { | |
| 172 | - | out.push(Node::empty("Nothing ahead.")); | |
| 173 | - | } else { | |
| 174 | - | out.push(Node::list( | |
| 175 | - | upcoming.iter().map(|event| row_for(event, false)), | |
| 176 | - | )); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | if !past.is_empty() { | |
| 180 | - | out.push(Node::section(format!("Past ({})", past.len()))); | |
| 181 | - | // Newest first. The recent end of the past is the end anyone looks at. | |
| 182 | - | out.push(Node::list( | |
| 183 | - | past.iter().rev().map(|event| row_for(event, false)), | |
| 184 | - | )); | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | Ok(out) | |
| 188 | 124 | } | |
| 189 | 125 | ||
| 190 | 126 | /// The address of the list under a given filter. | |
| @@ -208,41 +144,143 @@ | |||
| 208 | 144 | set(&request.carried) || set(&request.payload) | |
| 209 | 145 | } | |
| 210 | 146 | ||
| 211 | - | /// The whole screen. | |
| 212 | - | fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 213 | - | Ok(screen(state, snoozed_on(&request))?.into()) | |
| 147 | + | /// The calendar, split the three ways the screen draws it. | |
| 148 | + | struct Listing { | |
| 149 | + | /// The rules themselves, not their occurrences. | |
| 150 | + | templates: Vec<Event>, | |
| 151 | + | upcoming: Vec<Event>, | |
| 152 | + | /// Newest first. The recent end of the past is the end anyone looks at. | |
| 153 | + | past: Vec<Event>, | |
| 154 | + | /// Nothing on the calendar at all, which is a different statement from | |
| 155 | + | /// three empty sections. | |
| 156 | + | bare: bool, | |
| 157 | + | snoozed: bool, | |
| 214 | 158 | } | |
| 215 | 159 | ||
| 216 | - | /// The screen under one filter, which a read and a write both answer with. | |
| 217 | - | fn screen(state: &AppState, snoozed: bool) -> Result<Screen, RouteError> { | |
| 218 | - | let band = Slot::new("events-band", RegionKind::Band) | |
| 219 | - | .with(Node::page("Events")) | |
| 220 | - | .with(Node::act("New event", Action::get("/events/new"))) | |
| 221 | - | .with(Node::Token( | |
| 222 | - | Tag::chip("Snoozed", list_action(!snoozed)).latched(snoozed), | |
| 223 | - | )); | |
| 160 | + | /// Read the calendar. | |
| 161 | + | /// | |
| 162 | + | /// The snooze filter is applied here rather than read off a narrower query, and | |
| 163 | + | /// finding out which was the port's one real correction. `events.js` calls the | |
| 164 | + | /// `list_events` COMMAND, which excludes snoozed rows and offers | |
| 165 | + | /// `list_snoozed_events` to merge them back in. The repository's `list_all` is | |
| 166 | + | /// not that query: it filters only events converted into contexts, so a | |
| 167 | + | /// described screen reading it directly would have shown snoozed events always, | |
| 168 | + | /// which is not what the shipped screen does. | |
| 169 | + | /// | |
| 170 | + | /// Filtering here rather than adding a repository read keeps the two versions of | |
| 171 | + | /// "what is on the calendar" in one place, and there is no second list to | |
| 172 | + | /// de-duplicate against. | |
| 173 | + | fn read(state: &AppState, snoozed: bool) -> Result<Listing, RouteError> { | |
| 174 | + | let mut events = state | |
| 175 | + | .events | |
| 176 | + | .list_all(DESKTOP_USER_ID) | |
| 177 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 224 | 178 | ||
| 225 | - | let mut pane = Slot::new("events-list", RegionKind::Pane); | |
| 226 | - | for node in sections(state, snoozed)? { | |
| 227 | - | pane = pane.with(node); | |
| 179 | + | if !snoozed { | |
| 180 | + | events.retain(|event| !event.is_snoozed()); | |
| 228 | 181 | } | |
| 182 | + | let bare = events.is_empty(); | |
| 229 | 183 | ||
| 230 | - | Ok(Screen::list_detail("Events", false) | |
| 231 | - | .at_place(super::shell::EVENTS) | |
| 232 | - | .with(band) | |
| 233 | - | .with(pane) | |
| 234 | - | .with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))) | |
| 184 | + | let now = Utc::now(); | |
| 185 | + | let (templates, rest): (Vec<Event>, Vec<Event>) = events.into_iter().partition(is_template); | |
| 186 | + | let (mut past, upcoming): (Vec<Event>, Vec<Event>) = | |
| 187 | + | rest.into_iter().partition(|event| event.start_time < now); | |
| 188 | + | past.reverse(); | |
| 189 | + | ||
| 190 | + | Ok(Listing { | |
| 191 | + | templates, | |
| 192 | + | upcoming, | |
| 193 | + | past, | |
| 194 | + | bare, | |
| 195 | + | snoozed, | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// How the Recurring heading counts itself. | |
| 200 | + | fn recurring_heading(listing: &Listing) -> String { | |
| 201 | + | format!("Recurring ({})", listing.templates.len()) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// How the Past heading counts itself. | |
| 205 | + | fn past_heading(listing: &Listing) -> String { | |
| 206 | + | format!("Past ({})", listing.past.len()) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | declare! { | |
| 210 | + | /// The three sections, in the order the screen draws them. | |
| 211 | + | /// | |
| 212 | + | /// Recurring first, which is `events.js`'s own order and not alphabetical | |
| 213 | + | /// accident: the rules are what a reader scans for, and they are the | |
| 214 | + | /// shortest list. | |
| 215 | + | shape sections(listing: &Listing) -> Slot; | |
| 216 | + | ||
| 217 | + | region "events-list" as Pane { | |
| 218 | + | empty "No events scheduled." when listing.bare; | |
| 219 | + | ||
| 220 | + | section recurring_heading(listing) unless listing.templates.is_empty(); | |
| 221 | + | list { | |
| 222 | + | for event in listing.templates.iter() { | |
| 223 | + | include row_for(event, true); | |
| 224 | + | } | |
| 225 | + | } unless listing.templates.is_empty(); | |
| 226 | + | ||
| 227 | + | section "Upcoming" unless listing.bare; | |
| 228 | + | empty "Nothing ahead." when listing.upcoming.is_empty() and not listing.bare; | |
| 229 | + | list { | |
| 230 | + | for event in listing.upcoming.iter() { | |
| 231 | + | include row_for(event, false); | |
| 232 | + | } | |
| 233 | + | } unless listing.upcoming.is_empty(); | |
| 234 | + | ||
| 235 | + | section past_heading(listing) unless listing.past.is_empty(); | |
| 236 | + | list { | |
| 237 | + | for event in listing.past.iter() { | |
| 238 | + | include row_for(event, false); | |
| 239 | + | } | |
| 240 | + | } unless listing.past.is_empty(); | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | declare! { | |
| 245 | + | /// The screen under one filter, which a read and a write both answer with. | |
| 246 | + | shape screen(listing: &Listing) -> Screen; | |
| 247 | + | ||
| 248 | + | screen list_detail "Events" false { | |
| 249 | + | at_place super::shell::EVENTS; | |
| 250 | + | ||
| 251 | + | region "events-band" as Band { | |
| 252 | + | page "Events"; | |
| 253 | + | act "New event" to get "/events/new"; | |
| 254 | + | chip "Snoozed" to doing list_action(not_snoozed(listing)) { | |
| 255 | + | latched listing.snoozed; | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | include sections(listing); | |
| 260 | + | ||
| 261 | + | region "events-detail" as Pane { | |
| 262 | + | empty "Nothing selected"; | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// The filter a press on the Snoozed chip leaves the list under. | |
| 268 | + | fn not_snoozed(listing: &Listing) -> bool { | |
| 269 | + | !listing.snoozed | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// The whole screen, as an answer. | |
| 273 | + | fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 274 | + | Ok(screen(&read(state, snoozed_on(&request))?).into()) | |
| 235 | 275 | } | |
| 236 | 276 | ||
| 237 | 277 | /// The list alone, which is what the filter and a delete swap. | |
| 238 | 278 | fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 239 | - | let snoozed = snoozed_on(&request); | |
| 240 | - | let node = sections(state, snoozed)?; | |
| 241 | - | let mut region = Slot::new("events-list", RegionKind::Pane); | |
| 242 | - | for section in node { | |
| 243 | - | region = region.with(section); | |
| 244 | - | } | |
| 245 | - | Ok(Response::fragment("events-list", Node::Region(region))) | |
| 279 | + | let listing = read(state, snoozed_on(&request))?; | |
| 280 | + | Ok(Response::fragment( | |
| 281 | + | "events-list", | |
| 282 | + | Node::Region(sections(&listing)), | |
| 283 | + | )) | |
| 246 | 284 | } | |
| 247 | 285 | ||
| 248 | 286 | /// Load one event, or answer 404. | |
| @@ -265,70 +303,50 @@ | |||
| 265 | 303 | )) | |
| 266 | 304 | } | |
| 267 | 305 | ||
| 268 | - | /// The detail pane. | |
| 269 | - | /// | |
| 270 | - | /// What the modal shows, minus the controls it carries. `events.js:open` draws | |
| 271 | - | /// title, when, where, project, contact, description, and the reminder list; | |
| 272 | - | /// the reminders are absent here for the reason in the module header. | |
| 306 | + | declare! { | |
| 307 | + | /// One event's detail pane. | |
| 308 | + | /// | |
| 309 | + | /// What the modal shows, minus the controls it carries. `events.js:open` | |
| 310 | + | /// draws title, when, where, project, contact, description, and the | |
| 311 | + | /// reminder list; the reminders are absent here for the reason in the | |
| 312 | + | /// module header. | |
| 313 | + | /// | |
| 314 | + | /// Delete is offered on an occurrence and withheld on a template, which is | |
| 315 | + | /// the scope question the JS asks with a dialog. Refusing is not the answer | |
| 316 | + | /// it should end at; it is the honest state until a write can pause for one. | |
| 317 | + | shape detail_pane(event: &Event) -> Slot; | |
| 318 | + | ||
| 319 | + | region "events-detail" as Pane { | |
| 320 | + | section &event.title; | |
| 321 | + | text "{event.date_formatted()} at {event.time_formatted()}"; | |
| 322 | + | ||
| 323 | + | text event.location_or_empty().to_owned() when event.has_location(); | |
| 324 | + | text "Project: {event.project_name_or_empty()}" when event.has_project(); | |
| 325 | + | ||
| 326 | + | for contact in event.contact_name.iter() { | |
| 327 | + | text "With: {contact}"; | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | text event.description.clone() when event.has_description(); | |
| 331 | + | text "Repeats: {lead(event, true)}" when event.has_recurrence(); | |
| 332 | + | ||
| 333 | + | act "Edit" to get "/events/{event.id}/edit"; | |
| 334 | + | ||
| 335 | + | empty "This is a recurring rule. Deleting it needs the scope question, \ | |
| 336 | + | which nothing describes yet." | |
| 337 | + | when is_template(event); | |
| 338 | + | ||
| 339 | + | act "Delete" to post "/events/{event.id}/delete" unless is_template(event); | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// One event's detail pane, as an answer. | |
| 273 | 344 | fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 274 | 345 | let event = load(state, event_id(&request)?)?; | |
| 275 | - | ||
| 276 | - | let mut nodes = vec![ | |
| 277 | - | Node::section(&event.title), | |
| 278 | - | Node::text(format!( | |
| 279 | - | "{} at {}", | |
| 280 | - | event.date_formatted(), | |
| 281 | - | event.time_formatted() | |
| 282 | - | )), | |
| 283 | - | ]; | |
| 284 | - | ||
| 285 | - | if event.has_location() { | |
| 286 | - | nodes.push(Node::text(event.location_or_empty().to_owned())); | |
| 287 | - | } | |
| 288 | - | if event.has_project() { | |
| 289 | - | nodes.push(Node::text(format!( | |
| 290 | - | "Project: {}", | |
| 291 | - | event.project_name_or_empty() | |
| 292 | - | ))); | |
| 293 | - | } | |
| 294 | - | if let Some(contact) = &event.contact_name { | |
| 295 | - | nodes.push(Node::text(format!("With: {contact}"))); | |
| 296 | - | } | |
| 297 | - | if event.has_description() { | |
| 298 | - | nodes.push(Node::text(event.description.clone())); | |
| 299 | - | } | |
| 300 | - | if event.has_recurrence() { | |
| 301 | - | let label = event.effective_recurrence_rule().map_or_else( | |
| 302 | - | || event.recurrence.as_str().to_owned(), | |
| 303 | - | |rule| rule.display(), | |
| 304 | - | ); | |
| 305 | - | nodes.push(Node::text(format!("Repeats: {label}"))); | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | // Delete is offered on an occurrence and withheld on a template, which is | |
| 309 | - | // the scope question the JS asks with a dialog. Refusing is not the answer | |
| 310 | - | // it should end at; it is the honest state until a write can pause for one. | |
| 311 | - | nodes.push(Node::act( | |
| 312 | - | "Edit".to_owned(), | |
| 313 | - | Action::get(format!("/events/{}/edit", event.id)), | |
| 314 | - | )); | |
| 315 | - | ||
| 316 | - | if is_template(&event) { | |
| 317 | - | nodes.push(Node::empty( | |
| 318 | - | "This is a recurring rule. Deleting it needs the scope question, which nothing describes yet.", | |
| 319 | - | )); | |
| 320 | - | } else { | |
| 321 | - | nodes.push(Node::act( | |
| 322 | - | "Delete".to_owned(), | |
| 323 | - | Action::post(format!("/events/{}/delete", event.id)), | |
| 324 | - | )); | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | let mut region = Slot::new("events-detail", RegionKind::Pane); | |
| 328 | - | for node in nodes { | |
| 329 | - | region = region.with(node); | |
| 330 | - | } | |
| 331 | - | Ok(Response::fragment("events-detail", Node::Region(region))) | |
| 346 | + | Ok(Response::fragment( | |
| 347 | + | "events-detail", | |
| 348 | + | Node::Region(detail_pane(&event)), | |
| 349 | + | )) | |
| 332 | 350 | } | |
| 333 | 351 | ||
| 334 | 352 | /// Delete one event, and answer with the list it came out of. | |
| @@ -358,24 +376,57 @@ | |||
| 358 | 376 | ||
| 359 | 377 | // The form. `8fdb814c`. | |
| 360 | 378 | ||
| 379 | + | /// One entry in a fixed select: what it submits, and what it reads. | |
| 380 | + | /// | |
| 381 | + | /// A struct rather than a pair, because a description names what it draws and | |
| 382 | + | /// `.1` is not a name. | |
| 383 | + | struct Offered { | |
| 384 | + | value: &'static str, | |
| 385 | + | label: &'static str, | |
| 386 | + | } | |
| 387 | + | ||
| 361 | 388 | /// How an event's time relates to a zone, labelled as `events.js` labels it: | |
| 362 | 389 | /// for what the choice does rather than for the stored word, because "relative | |
| 363 | 390 | /// vs local vs absolute" means nothing standing in front of a calendar. | |
| 364 | - | const TZ_KINDS: [(&str, &str); 3] = [ | |
| 365 | - | ("relative", "Relative to me (follows me when I travel)"), | |
| 366 | - | ("local", "Anchored to a place"), | |
| 367 | - | ("absolute", "Fixed point in time"), | |
| 391 | + | const TZ_KINDS: [Offered; 3] = [ | |
| 392 | + | Offered { | |
| 393 | + | value: "relative", | |
| 394 | + | label: "Relative to me (follows me when I travel)", | |
| 395 | + | }, | |
| 396 | + | Offered { | |
| 397 | + | value: "local", | |
| 398 | + | label: "Anchored to a place", | |
| 399 | + | }, | |
| 400 | + | Offered { | |
| 401 | + | value: "absolute", | |
| 402 | + | label: "Fixed point in time", | |
| 403 | + | }, | |
| 368 | 404 | ]; | |
| 369 | 405 | ||
| 370 | 406 | /// The block types the shipped select offers, empty first for an ordinary | |
| 371 | 407 | /// event. The empty option is what clears one, which is why it is a real | |
| 372 | 408 | /// member here rather than an absent value. | |
| 373 | - | const BLOCK_TYPES: [(&str, &str); 5] = [ | |
| 374 | - | ("", "Regular Event"), | |
| 375 | - | ("free_time", "Free Time"), | |
| 376 | - | ("personal", "Personal"), | |
| 377 | - | ("vacation", "Vacation"), | |
| 378 | - | ("focus", "Focus"), | |
| 409 | + | const BLOCK_TYPES: [Offered; 5] = [ | |
| 410 | + | Offered { | |
| 411 | + | value: "", | |
| 412 | + | label: "Regular Event", | |
| 413 | + | }, | |
| 414 | + | Offered { | |
| 415 | + | value: "free_time", | |
| 416 | + | label: "Free Time", | |
| 417 | + | }, | |
| 418 | + | Offered { | |
| 419 | + | value: "personal", | |
| 420 | + | label: "Personal", | |
| 421 | + | }, | |
| 422 | + | Offered { | |
| 423 | + | value: "vacation", | |
| 424 | + | label: "Vacation", | |
| 425 | + | }, | |
| 426 | + | Offered { | |
| 427 | + | value: "focus", | |
| 428 | + | label: "Focus", |
Lines truncated
| @@ -90,11 +90,10 @@ | |||
| 90 | 90 | MilestoneId, Priority, ProjectId, SortDirection, Task, TaskFilterQuery, TaskId, TaskSortColumn, | |
| 91 | 91 | TaskStatus, | |
| 92 | 92 | }; | |
| 93 | - | use makeover_layout::{Sort, Tone, Width}; | |
| 94 | - | use quasi_router::screen::{ | |
| 95 | - | Act, Cell, Cells, Choice, Column, Consult, Field, Figure, Meter, Rest, Table, Tag, | |
| 96 | - | }; | |
| 97 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 93 | + | use makeover_layout::{Sort, Tone}; | |
| 94 | + | use quasi_declare::declare; | |
| 95 | + | use quasi_router::screen::{Choice, Consult, Figure, Meter, Rest, Tag}; | |
| 96 | + | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot}; | |
| 98 | 97 | ||
| 99 | 98 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 100 | 99 | ||
| @@ -111,26 +110,6 @@ | |||
| 111 | 110 | /// Ten pages, which stops a hand-typed address asking for a million rows. | |
| 112 | 111 | const PAGES: i64 = 10; | |
| 113 | 112 | ||
| 114 | - | /// The table, left to right. | |
| 115 | - | /// | |
| 116 | - | /// Seven columns, four of them sortable. The priority a column has when room | |
| 117 | - | /// runs out is `build.rs`'s business, because it generates the narrowing CSS, | |
| 118 | - | /// so only the width is stated here; a renderer with no stylesheet reads it off | |
| 119 | - | /// the same order. | |
| 120 | - | const COLUMNS: [(&str, Width, Option<TaskSortColumn>); 7] = [ | |
| 121 | - | ( | |
| 122 | - | "description", | |
| 123 | - | Width::Fill, | |
| 124 | - | Some(TaskSortColumn::Description), | |
| 125 | - | ), | |
| 126 | - | ("project", Width::Fixed, Some(TaskSortColumn::Project)), | |
| 127 | - | ("priority", Width::Fixed, Some(TaskSortColumn::Priority)), | |
| 128 | - | ("due", Width::Fixed, Some(TaskSortColumn::Due)), | |
| 129 | - | ("recurrence", Width::Fixed, None), | |
| 130 | - | ("progress", Width::Fixed, None), | |
| 131 | - | ("actions", Width::Fixed, None), | |
| 132 | - | ]; | |
| 133 | - | ||
| 134 | 113 | /// What the screen calls its set of ticks. | |
| 135 | 114 | /// | |
| 136 | 115 | /// One name, in one place: [`Screen::selecting`] declares it, every row joins | |
| @@ -435,6 +414,78 @@ | |||
| 435 | 414 | } | |
| 436 | 415 | } | |
| 437 | 416 | ||
| 417 | + | /// The same view under one status. | |
| 418 | + | fn with_status(&self, status: Option<TaskStatus>) -> Self { | |
| 419 | + | Self { | |
| 420 | + | status, | |
| 421 | + | ..self.first_page() | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /// The same view with this priority on, or off if it already was. | |
| 426 | + | /// | |
| 427 | + | /// Pressing the latched one clears it, so the way back is always on screen. | |
| 428 | + | /// The contacts tag filter's rule. | |
| 429 | + | fn toggling_priority(&self, priority: &Priority) -> Self { | |
| 430 | + | Self { | |
| 431 | + | priority: (self.priority.as_ref() != Some(priority)).then(|| priority.clone()), | |
| 432 | + | ..self.first_page() | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | /// The same view with the snoozed rows the other way round. | |
| 437 | + | fn toggling_snoozed(&self) -> Self { | |
| 438 | + | Self { | |
| 439 | + | snoozed: !self.snoozed, | |
| 440 | + | ..self.first_page() | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /// The same view cut to what is waiting, or not. | |
| 445 | + | fn toggling_waiting(&self) -> Self { | |
| 446 | + | Self { | |
| 447 | + | waiting: !self.waiting, | |
| 448 | + | ..self.first_page() | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// The same view with no project and no milestone. | |
| 453 | + | /// | |
| 454 | + | /// Clearing the project clears the milestone with it: a milestone id that | |
| 455 | + | /// outlived its project filters to a project the view no longer names, and | |
| 456 | + | /// the control that would clear it is not on screen. | |
| 457 | + | fn clearing_project(&self) -> Self { | |
| 458 | + | Self { | |
| 459 | + | project: None, | |
| 460 | + | milestone: None, | |
| 461 | + | ..self.first_page() | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | /// The same view with no milestone. | |
| 466 | + | fn clearing_milestone(&self) -> Self { | |
| 467 | + | Self { | |
| 468 | + | milestone: None, | |
| 469 | + | ..self.first_page() | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | /// The same view with every row arriving ticked, or none of them. | |
| 474 | + | fn ticking(&self, ticked: bool) -> Self { | |
| 475 | + | Self { | |
| 476 | + | ticked, | |
| 477 | + | ..self.clone() | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | /// The same view showing one more page. | |
| 482 | + | fn showing(&self, shown: i64) -> Self { | |
| 483 | + | Self { | |
| 484 | + | shown, | |
| 485 | + | ..self.clone() | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 438 | 489 | /// What this view asks the repository for. | |
| 439 | 490 | fn query(&self) -> TaskFilterQuery { | |
| 440 | 491 | TaskFilterQuery { | |
| @@ -512,282 +563,185 @@ | |||
| 512 | 563 | Some(SystemTime::UNIX_EPOCH + Duration::from_secs(u64::try_from(started.timestamp()).ok()?)) | |
| 513 | 564 | } | |
| 514 | 565 | ||
| 515 | - | /// The description cell: what the task is, and everything true of it that has | |
| 516 | - | /// no column of its own. | |
| 517 | - | /// | |
| 518 | - | /// Subtask progress is the progress column's meter and is not repeated here: | |
| 519 | - | /// one fact in two places can disagree only by being computed twice. The | |
| 520 | - | /// started state is a badge, since a class is not a fact a description can | |
| 521 | - | /// carry. | |
| 522 | - | /// | |
| 523 | - | /// Nothing here lives only in a `title`: a hover-only fact is one a touch or | |
| 524 | - | /// keyboard user never sees, so it becomes a badge that says what it means. | |
| 525 | - | fn description_cell(task: &Task) -> Cell { | |
| 526 | - | let mut cell = | |
| 527 | - | Cell::new(task.title.clone()).activate(Action::get(format!("/tasks/{}", task.id))); | |
| 528 | - | ||
| 529 | - | if task.status == TaskStatus::Started { | |
| 530 | - | cell = cell.token(Tag::badge("Started").tone(Tone::Info)); | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | if let Some(marker) = super::Availability::of(task).marker() { | |
| 534 | - | cell = cell.token(marker); | |
| 535 | - | } | |
| 536 | - | ||
| 537 | - | if task.has_status_tokens() { | |
| 538 | - | cell = cell.token(match task.status_token_summary() { | |
| 539 | - | "complete" => Tag::badge("Commits pushed").tone(Tone::Success), | |
| 540 | - | _ => Tag::badge("Commits unpushed").tone(Tone::Warning), | |
| 541 | - | }); | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | if let Some(started) = running_for(task) { | |
| 545 | - | cell = cell.part(Node::since(started)); | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | if let Some(time) = time_token(task) { | |
| 549 | - | cell = cell.token(time); | |
| 550 | - | } | |
| 551 | - | ||
| 552 | - | if task.has_annotations() { | |
| 553 | - | cell = cell.token(Tag::badge(format!("Notes: {}", task.annotation_count()))); | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | if let Some(contact) = &task.contact_name { | |
| 557 | - | cell = cell.token(Tag::badge(contact).tone(Tone::Neutral)); | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | if task.is_snoozed() { | |
| 561 | - | cell = cell.token(Tag::badge("Snoozed").tone(Tone::Neutral)); | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | if task.is_waiting() { | |
| 565 | - | cell = cell.token(Tag::badge("Waiting").tone(if task.is_response_overdue() { | |
| 566 | - | Tone::Warning | |
| 567 | - | } else { | |
| 568 | - | Tone::Neutral | |
| 569 | - | })); | |
| 570 | - | } | |
| 571 | - | ||
| 572 | - | cell | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | /// The moves a row offers. See the module header for the eight it does not. | |
| 576 | - | fn acts_for(task: &Task, view: &View) -> Vec<Act> { | |
| 577 | - | let id = task.id; | |
| 578 | - | let status = |label: &str, to: &str| { | |
| 579 | - | Act::new( | |
| 580 | - | label, | |
| 581 | - | view.carry(Action::post(format!("/tasks/list/{id}/status")).with("status", to)), | |
| 582 | - | ) | |
| 583 | - | }; | |
| 584 | - | ||
| 585 | - | let mut acts = Vec::with_capacity(3); | |
| 586 | - | match task.status { | |
| 587 | - | TaskStatus::Pending => { | |
| 588 | - | acts.push(status("Start", "Started")); | |
| 589 | - | acts.push(status("Complete", "Completed")); | |
| 590 | - | } | |
| 591 | - | TaskStatus::Started => acts.push(status("Complete", "Completed")), | |
| 592 | - | // Reopening is the board's leftward drop, offered here because a | |
| 593 | - | // completed task is reachable through the status filter and a row with | |
| 594 | - | // no move at all is a dead row. | |
| 595 | - | TaskStatus::Completed => acts.push(status("Reopen", "Pending")), | |
| 596 | - | TaskStatus::Deleted => {} | |
| 597 | - | } | |
| 598 | - | acts.push( | |
| 599 | - | Act::new( | |
| 600 | - | "Delete", | |
| 601 | - | view.carry(Action::post(format!("/tasks/list/{id}/delete"))), | |
| 602 | - | ) | |
| 603 | - | .tone(Tone::Danger) | |
| 604 | - | .confirm("Are you sure you want to delete this task? This cannot be undone."), | |
| 605 | - | ); | |
| 606 | - | acts | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | /// One task as a row of cells, each naming the column it belongs to. | |
| 610 | - | /// | |
| 611 | - | /// The names are [`COLUMNS`]'s own words, resolved by [`Table::row`] against | |
| 612 | - | /// the columns [`columns`] built from that same constant. Every row does carry | |
| 613 | - | /// all seven cells, so position would land them correctly today; what it would | |
| 614 | - | /// not survive is the two lists living in different functions, where the only | |
| 615 | - | /// thing holding them in the same order is somebody reading both. | |
| 616 | - | fn row_for(task: &Task, view: &View) -> Cells { | |
| 617 | - | let progress = if task.subtask_count() > 0 { | |
| 618 | - | Cell::default().part(Node::Meter(Meter { | |
| 619 | - | done: u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX), | |
| 620 | - | total: u32::try_from(task.subtask_count()).unwrap_or(u32::MAX), | |
| 621 | - | tone: Tone::Success, | |
| 622 | - | label: None, | |
| 623 | - | })) | |
| 566 | + | /// What a task's commits say about themselves. | |
| 567 | + | fn commit_label(task: &Task) -> &'static str { | |
| 568 | + | if task.status_token_summary() == "complete" { | |
| 569 | + | "Commits pushed" | |
| 624 | 570 | } else { | |
| 625 | - | Cell::new("-") | |
| 626 | - | }; | |
| 571 | + | "Commits unpushed" | |
| 572 | + | } | |
| 573 | + | } | |
| 627 | 574 | ||
| 628 | - | // Overdue is a judgment the app makes and a renderer cannot, so it travels | |
| 629 | - | // as a tone rather than as the `task-overdue` class the JS puts on the row. | |
| 630 | - | let due = Cell::tag(Tag::badge(task.due_formatted()).tone(if task.is_overdue() { | |
| 575 | + | /// And what that means. | |
| 576 | + | fn commit_tone(task: &Task) -> Tone { | |
| 577 | + | if task.status_token_summary() == "complete" { | |
| 578 | + | Tone::Success | |
| 579 | + | } else { | |
| 580 | + | Tone::Warning | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | /// Whether a waiting task has been waiting too long. | |
| 585 | + | fn waiting_tone(task: &Task) -> Tone { | |
| 586 | + | if task.is_response_overdue() { | |
| 587 | + | Tone::Warning | |
| 588 | + | } else { | |
| 589 | + | Tone::Neutral | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | /// Whether the task can still be finished. | |
| 594 | + | fn can_complete(task: &Task) -> bool { | |
| 595 | + | matches!(task.status, TaskStatus::Pending | TaskStatus::Started) | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | /// Whether the task has subtasks to show progress over. | |
| 599 | + | fn has_subtasks(task: &Task) -> bool { | |
| 600 | + | task.subtask_count() > 0 | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// A count as a meter reads it. Never negative, never overflowing. | |
| 604 | + | fn measured(n: usize) -> u32 { | |
| 605 | + | u32::try_from(n).unwrap_or(u32::MAX) | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | /// What the progress cell says when there is nothing to measure. | |
| 609 | + | fn progress_dash(task: &Task) -> &'static str { | |
| 610 | + | if has_subtasks(task) { "" } else { "-" } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | /// What the recurrence cell says. | |
| 614 | + | fn recurrence_word(task: &Task) -> &str { | |
| 615 | + | if task.has_recurrence() { | |
| 616 | + | task.recurrence.as_str() | |
| 617 | + | } else { | |
| 618 | + | "-" | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | /// What a due date means. | |
| 623 | + | /// | |
| 624 | + | /// Overdue is a judgment the app makes and a renderer cannot, so it travels as a | |
| 625 | + | /// tone rather than as the `task-overdue` class the JS puts on the row. | |
| 626 | + | fn due_tone(task: &Task) -> Tone { | |
| 627 | + | if task.is_overdue() { | |
| 631 | 628 | Tone::Danger | |
| 632 | 629 | } else { | |
| 633 | 630 | Tone::Neutral | |
| 634 | - | })); | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | declare! { | |
| 635 | + | /// One task as a row of cells, each naming the column it belongs to. | |
| 636 | + | /// | |
| 637 | + | /// The names are [`columns`]'s own words, resolved by [`Table::row`] against | |
| 638 | + | /// the columns declared beside them. Every row does carry all seven cells, | |
| 639 | + | /// so position would land them correctly today; what it would not survive is | |
| 640 | + | /// the two lists living in different bodies, where the only thing holding | |
| 641 | + | /// them in the same order is somebody reading both. | |
| 642 | + | /// | |
| 643 | + | /// # The description cell | |
| 644 | + | /// | |
| 645 | + | /// What the task is, and everything true of it that has no column of its | |
| 646 | + | /// own. Subtask progress is the progress column's meter and is not repeated | |
| 647 | + | /// here: one fact in two places can disagree only by being computed twice. | |
| 648 | + | /// The started state is a badge, since a class is not a fact a description | |
| 649 | + | /// can carry. | |
| 650 | + | /// | |
| 651 | + | /// Nothing here lives only in a `title`: a hover-only fact is one a touch or | |
| 652 | + | /// keyboard user never sees, so it becomes a badge that says what it means. | |
| 653 | + | /// | |
| 654 | + | /// # The moves | |
| 655 | + | /// | |
| 656 | + | /// Reopening is the board's leftward drop, offered here because a completed | |
| 657 | + | /// task is reachable through the status filter and a row with no move at all | |
| 658 | + | /// is a dead row. See the module header for the eight moves the row does not | |
| 659 | + | /// offer. | |
| 660 | + | shape row_for(listing: &Listing, task: &Task) -> Cells; | |
| 661 | + | ||
| 662 | + | cells { | |
| 663 | + | cell at "description" task.title.clone() { | |
| 664 | + | activate to get "/tasks/{task.id}"; | |
| 665 | + | ||
| 666 | + | token Tag::badge("Started").tone(Tone::Info) | |
| 667 | + | when task.status is TaskStatus::Started; | |
| 668 | + | ||
| 669 | + | for marker in super::Availability::of(task).marker().into_iter() { | |
| 670 | + | token marker; | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | token Tag::badge(commit_label(task)).tone(commit_tone(task)) | |
| 674 | + | when task.has_status_tokens(); | |
| 675 | + | ||
| 676 | + | for started in running_for(task).into_iter() { | |
| 677 | + | since started; | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | for time in time_token(task).into_iter() { | |
| 681 | + | token time; | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | token Tag::badge("Notes: {task.annotation_count()}") when task.has_annotations(); | |
| 685 | + | ||
| 686 | + | for contact in task.contact_name.iter() { | |
| 687 | + | token Tag::badge(contact).tone(Tone::Neutral); | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | token Tag::badge("Snoozed").tone(Tone::Neutral) when task.is_snoozed(); | |
| 691 | + | token Tag::badge("Waiting").tone(waiting_tone(task)) when task.is_waiting(); | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | cell at "project" task.project_name_or_dash(); | |
| 635 | 695 | ||
| 636 | - | Cells::default() | |
| 637 | - | .at("description", description_cell(task)) | |
| 638 | - | .at("project", Cell::new(task.project_name_or_dash())) | |
| 639 | 696 | // The single letter the shipped column shows, which is the whole cell. | |
| 640 | 697 | // `as_str` is the right method here and only here: this is the | |
| 641 | 698 | // one-character column it was written for. | |
| 642 | - | .at( | |
| 643 | - | "priority", | |
| 644 | - | Cell::tag(Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority))), | |
| 645 | - | ) | |
| 646 | - | .at("due", due) | |
| 647 | - | .at( | |
| 648 | - | "recurrence", | |
| 649 | - | Cell::new(if task.has_recurrence() { | |
| 650 | - | task.recurrence.as_str() | |
| 651 | - | } else { | |
| 652 | - | "-" | |
| 653 | - | }), | |
| 654 | - | ) | |
| 655 | - | .at("progress", progress) | |
| 656 | - | .at("actions", Cell::acts(acts_for(task, view))) | |
| 699 | + | cell at "priority" "" { | |
| 700 | + | token Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority)); | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | cell at "due" "" { | |
| 704 | + | token Tag::badge(task.due_formatted()).tone(due_tone(task)); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | cell at "recurrence" recurrence_word(task); | |
| 708 | + | ||
| 709 | + | cell at "progress" progress_dash(task) { | |
| 710 | + | meter Meter::new(measured(task.subtasks_completed()), measured(task.subtask_count())) | |
| 711 | + | .tone(Tone::Success) | |
| 712 | + | when has_subtasks(task); | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | cell at "actions" "" { | |
| 716 | + | act "Start" | |
| 717 | + | to doing listing.view.carry( | |
| 718 | + | Action::post("/tasks/list/{task.id}/status").with("status", "Started") | |
| 719 | + | ) | |
| 720 | + | when task.status is TaskStatus::Pending; | |
| 721 | + | ||
| 722 | + | act "Complete" | |
| 723 | + | to doing listing.view.carry( | |
| 724 | + | Action::post("/tasks/list/{task.id}/status").with("status", "Completed") | |
| 725 | + | ) | |
| 726 | + | when can_complete(task); | |
| 727 | + | ||
| 728 | + | act "Reopen" | |
| 729 | + | to doing listing.view.carry( | |
| 730 | + | Action::post("/tasks/list/{task.id}/status").with("status", "Pending") | |
| 731 | + | ) | |
| 732 | + | when task.status is TaskStatus::Completed; | |
| 733 | + | ||
| 734 | + | act "Delete" | |
| 735 | + | to doing listing.view.carry(Action::post("/tasks/list/{task.id}/delete")) { | |
| 736 | + | tone Danger; | |
| 737 | + | confirm "Are you sure you want to delete this task? This cannot be undone."; | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | ||
| 657 | 741 | // The row joins the screen's selection under its own id, which is what | |
| 658 | 742 | // the bulk bar acts on. | |
| 659 | - | .ticking(task.id.to_string(), view.ticked) | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | /// The controls over the selection. | |
| 663 | - | /// | |
| 664 | - | /// Five acts. Complete and Delete act on the set and carry no value; priority, | |
| 665 | - | /// project and snooze apply a value, and reach it through [`Act::asks`] rather | |
| 666 | - | /// than by being pickers that write as they change. | |
| 667 | - | /// | |
| 668 | - | /// Said as plain acts, a project picker is one button per project. `Act::asks` | |
| 669 | - | /// answers that without | |
| 670 | - | /// the write, because the value rides with the press instead of firing on its | |
| 671 | - | /// own, so a picker over forty projects is still one control. Under | |
| 672 | - | /// wiki `explicit-commit-affordance` the press is also the commit the reader | |
| 673 | - | /// needs, and a bar whose ticks stage while its pickers write was half staged | |
| 674 | - | /// and half live. | |
| 675 | - | /// | |
| 676 | - | /// # What is not here | |
| 677 | - | /// | |
| 678 | - | /// The count. `tasks.js` writes "3 selected" into the bar and hides it when the | |
| 679 | - | /// selection is empty, and neither is sayable: the ticks are the host's until | |
| 680 | - | /// something submits them, so the description does not know how many there are. | |
| 681 | - | /// That is the right place for it — a renderer knows exactly, and a webview one | |
| 682 | - | /// can count its own boxes — and it is a gap in the renderers rather than in | |
| 683 | - | /// the vocabulary. The bar is always on screen here, which is the honest | |
| 684 | - | /// version of not knowing. | |
| 685 | - | fn bulk(state: &AppState, view: &View) -> Result<Vec<Node>, RouteError> { | |
| 686 | - | let over = |action: Action| view.carry(action); | |
| 687 | - | let mut out = vec![ | |
| 688 | - | Node::Act(Act::new("Complete", over(Action::post("/tasks/list/complete"))).over(SELECTION)), | |
| 689 | - | Node::Act( | |
| 690 | - | Act::new("Delete", over(Action::post("/tasks/list/delete"))) | |
| 691 | - | .tone(Tone::Danger) | |
| 692 | - | .over(SELECTION) | |
| 693 | - | .confirm("Delete every selected task? This cannot be undone."), | |
| 694 | - | ), | |
| 695 | - | ]; | |
| 696 | - | ||
| 697 | - | // Select-all is an address: see `View::ticked`. Its opposite is the same | |
| 698 | - | // address without it, and only offered when there is something to clear. | |
| 699 | - | out.push(Node::act( | |
| 700 | - | "Select all", | |
| 701 | - | View { | |
| 702 | - | ticked: true, | |
| 703 | - | ..view.clone() | |
| 704 | - | } | |
| 705 | - | .list(), | |
| 706 | - | )); | |
| 707 | - | if view.ticked { | |
| 708 | - | out.push(Node::act( | |
| 709 | - | "Clear selection", | |
| 710 | - | View { | |
| 711 | - | ticked: false, | |
| 712 | - | ..view.clone() | |
| 713 | - | } | |
| 714 | - | .list(), | |
| 715 | - | )); | |
| 743 | + | ticking task.id.to_string() listing.view.ticked; | |
| 716 | 744 | } | |
| 717 | - | ||
| 718 | - | // The three value controls are acts that ask, not fields that write. A tick | |
| 719 | - | // stages and never writes (`5f2b8753`), so a bar whose ticks stage and whose | |
| 720 | - | // pickers write on change is half staged and half live. Pressing the verb is | |
| 721 | - | // the commit, and the picker it reveals is the value that commit carries. | |
| 722 | - | // | |
| 723 | - | // No blank leading option any more. It existed because a bare select opens | |
| 724 | - | // on its first option, and one opening on "High" read as though the | |
| 725 | - | // selection already had a priority. A picker that is not on screen until |
Lines truncated
| @@ -37,8 +37,9 @@ | |||
| 37 | 37 | }; | |
| 38 | 38 | use goingson_core::{LinkedTaskRef, Task, TaskId}; | |
| 39 | 39 | use makeover_layout::Tone; | |
| 40 | - | use quasi_router::screen::{Act, Field, Figure, Row, Tag}; | |
| 41 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 40 | + | use quasi_declare::declare; | |
| 41 | + | use quasi_router::screen::{Figure, Tag}; | |
| 42 | + | use quasi_router::{Action, Response, RouteError, Router}; | |
| 42 | 43 | ||
| 43 | 44 | use crate::commands::{focus_blockers, gather_weekly_review}; | |
| 44 | 45 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -114,295 +115,20 @@ | |||
| 114 | 115 | } | |
| 115 | 116 | } | |
| 116 | 117 | ||
| 117 | - | /// One task, the way every list on this screen writes one. | |
| 118 | + | /// Pull one prompt's answer back out of the stored notes. | |
| 118 | 119 | /// | |
| 119 | - | /// The project is `meta` rather than a token: it is a plain trailing fact with | |
| 120 | - | /// no tone of its own and no click to answer, which is the line | |
| 121 | - | /// [`Row::tokens`](quasi_router::screen::Row::tokens) draws. | |
| 122 | - | fn task_row(task: &Task) -> Row { | |
| 123 | - | let row = Row::new(&task.title); | |
| 124 | - | match &task.project_name { | |
| 125 | - | Some(project) => row.meta(project), | |
| 126 | - | None => row, | |
| 127 | - | } | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | /// The week at a glance. | |
| 131 | - | /// | |
| 132 | - | /// # The first finding | |
| 133 | - | /// | |
| 134 | - | /// **A strip that runs across is described as a list that runs down, and two | |
| 135 | - | /// separate things are lost saying so.** | |
| 136 | - | /// | |
| 137 | - | /// The first loss is the direction, and it is not one worth closing. Seven days | |
| 138 | - | /// in a row and seven days in a column are the same seven facts, and a terminal | |
| 139 | - | /// renderer would draw the column whatever the description said. That is | |
| 140 | - | /// [`Node::Stats`]' grouping argument pointed the other way, and it comes out | |
| 141 | - | /// the other way: the set is already one node, so the renderer has what it | |
| 142 | - | /// needs to decide. | |
| 143 | - | /// | |
| 144 | - | /// The second is real. The JS encodes each day's counts as up to three dots, | |
| 145 | - | /// capping completed at 3 and events, overdue and due at 2. A dot is a | |
| 146 | - | /// renderer's encoding of a number and the cap is that encoding running out of | |
| 147 | - | /// room, so the description carries the numbers and lets a host that has room | |
| 148 | - | /// draw them as it likes. A day with 9 completed tasks reads as 9 here and as | |
| 149 | - | /// three dots there, and the description is the one that is right. | |
| 150 | - | /// | |
| 151 | - | /// Nothing is missing from the vocabulary here: capping a count is a thing to | |
| 152 | - | /// stop doing rather than a thing to describe. | |
| 153 | - | fn timeline(days: &[TimelineDayData]) -> Vec<Node> { | |
| 154 | - | let rows = days.iter().map(|day| { | |
| 155 | - | let mut counts = Vec::new(); | |
| 156 | - | if day.completed_count > 0 { | |
| 157 | - | counts.push(format!("{} done", day.completed_count)); | |
| 158 | - | } | |
| 159 | - | if day.event_count > 0 { | |
| 160 | - | counts.push(format!("{} events", day.event_count)); | |
| 161 | - | } | |
| 162 | - | if day.overdue_count > 0 { | |
| 163 | - | counts.push(format!("{} overdue", day.overdue_count)); | |
| 164 | - | } | |
| 165 | - | // Due is a future fact: a day that has passed has no tasks still due on | |
| 166 | - | // it, they are the overdue count above. `renderDayDots` says the same | |
| 167 | - | // with `if (!day.isPast)`. | |
| 168 | - | if !day.is_past && day.due_count > 0 { | |
| 169 | - | counts.push(format!("{} due", day.due_count)); | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | let mut row = Row::new(format!("{} {}", day.day_name, day.day_number)); | |
| 173 | - | if day.is_today { | |
| 174 | - | row = row.token(Tag::badge("Today").tone(makeover_layout::Tone::Info)); | |
| 175 | - | } | |
| 176 | - | if day.is_vacation { | |
| 177 | - | // A day off is why the counts are absent rather than zero, so it is | |
| 178 | - | // a token and not merely a style on the row. | |
| 179 | - | row = row.token(Tag::badge("Day off")); | |
| 180 | - | } | |
| 181 | - | if counts.is_empty() { | |
| 182 | - | row | |
| 183 | - | } else { | |
| 184 | - | row.meta(counts.join(", ")) | |
| 185 | - | } | |
| 186 | - | }); | |
| 187 | - | ||
| 188 | - | vec![Node::section("Week at a Glance"), Node::list(rows)] | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | /// One event, in the compact form both event lists here use. | |
| 192 | - | fn event_row(event: &EventSummary) -> Row { | |
| 193 | - | let row = Row::new(&event.title).meta(&event.formatted_time); | |
| 194 | - | match &event.project_name { | |
| 195 | - | Some(project) => row.token(Tag::badge(project)), | |
| 196 | - | None => row, | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | /// The week's events, under the day each fell on. | |
| 201 | - | /// | |
| 202 | - | /// A list holds rows and nothing else, so the grouping is headings between | |
| 203 | - | /// lists rather than anything inside one. That is the vocabulary working: the | |
| 204 | - | /// JS wraps each day in a `timeline-events-day` div because it needs somewhere | |
| 205 | - | /// to hang a label, and a heading is what the label actually is. | |
| 206 | - | fn week_events(days: &[TimelineDayData]) -> Vec<Node> { | |
| 207 | - | let with_events: Vec<&TimelineDayData> = | |
| 208 | - | days.iter().filter(|day| !day.events.is_empty()).collect(); | |
| 209 | - | if with_events.is_empty() { | |
| 210 | - | return Vec::new(); | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | let mut out = vec![Node::section("Week's Events")]; | |
| 214 | - | for day in with_events { | |
| 215 | - | out.push(Node::Heading { | |
| 216 | - | level: makeover_layout::Heading::Subsection, | |
| 217 | - | text: format!("{} {}", day.day_name, day.day_number), | |
| 218 | - | }); | |
| 219 | - | out.push(Node::list(day.events.iter().map(event_row))); | |
| 220 | - | } | |
| 221 | - | out | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | /// What got done. | |
| 225 | - | /// | |
| 226 | - | /// # The second finding | |
| 227 | - | /// | |
| 228 | - | /// **A cap that exists to fit a card is not a fact, and the description should | |
| 229 | - | /// not carry it.** | |
| 230 | - | /// | |
| 231 | - | /// The JS shows the first 6 completed tasks, the first 3 overdue and the first | |
| 232 | - | /// 3 carried over, with nothing saying there are more. Those numbers are the | |
| 233 | - | /// height of a card in a grid, which is the renderer's problem, and | |
| 234 | - | /// [`Rest`](quasi_router::screen::Rest) is not the answer to them: it wants an | |
| 235 | - | /// action, because `346567f9` was about lists with a real remainder to go and | |
| 236 | - | /// ask for. There is no address here holding "the rest of what you finished" and | |
| 237 | - | /// inventing one would be adding a screen to justify a cap. | |
| 238 | - | /// | |
| 239 | - | /// So every list on this screen is whole, and a renderer that can only draw six | |
| 240 | - | /// rows is the thing that decides that. The count stays as a figure beside it, | |
| 241 | - | /// which is the fact the badge was carrying. | |
| 242 | - | fn accomplished(data: &WeeklyReviewData) -> Vec<Node> { | |
| 243 | - | let mut out = vec![ | |
| 244 | - | Node::section("Accomplished"), | |
| 245 | - | Node::stats([ | |
| 246 | - | Figure::new(data.tasks_completed_count.to_string(), "Tasks Completed") | |
| 247 | - | .tone(makeover_layout::Tone::Success), | |
| 248 | - | Figure::new(data.events_occurred_count.to_string(), "Events Attended"), | |
| 249 | - | ]), | |
| 250 | - | ]; | |
| 251 | - | if data.tasks_completed.is_empty() { | |
| 252 | - | out.push(Node::empty("Nothing completed this week")); | |
| 253 | - | } else { | |
| 254 | - | out.push(Node::list(data.tasks_completed.iter().map(task_row))); | |
| 255 | - | } | |
| 256 | - | out | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | /// What slipped. | |
| 260 | - | /// | |
| 261 | - | /// Overdue and carried-over are one list, told apart by a token rather than by | |
| 262 | - | /// order: ordering relies on the reader noticing a red due date, which is a | |
| 263 | - | /// distinction that survives only for someone who can see both halves at | |
| 264 | - | /// once. | |
| 265 | - | fn needs_attention(data: &WeeklyReviewData) -> Vec<Node> { | |
| 266 | - | let mut out = vec![ | |
| 267 | - | Node::section("Needs Attention"), | |
| 268 | - | Node::stats([ | |
| 269 | - | Figure::new(data.tasks_overdue_count.to_string(), "Overdue").tone( | |
| 270 | - | if data.tasks_overdue_count > 0 { | |
| 271 | - | makeover_layout::Tone::Danger | |
| 272 | - | } else { | |
| 273 | - | makeover_layout::Tone::Neutral | |
| 274 | - | }, | |
| 275 | - | ), | |
| 276 | - | Figure::new(data.carried_over_count.to_string(), "Carried Over") | |
| 277 | - | .tone(makeover_layout::Tone::Info), | |
| 278 | - | ]), | |
| 279 | - | ]; | |
| 280 | - | ||
| 281 | - | let mut rows: Vec<Row> = data | |
| 282 | - | .tasks_overdue | |
| 283 | - | .iter() | |
| 284 | - | .map(|task| { | |
| 285 | - | task_row(task) | |
| 286 | - | .token(Tag::badge("Overdue").tone(makeover_layout::Tone::Danger)) | |
| 287 | - | .meta(task.due_formatted()) | |
| 288 | - | }) | |
| 289 | - | .collect(); | |
| 290 | - | rows.extend( | |
| 291 | - | data.carried_over_tasks | |
| 292 | - | .iter() | |
| 293 | - | .map(|task| task_row(task).token(Tag::badge("Carried over"))), | |
| 294 | - | ); | |
| 295 | - | ||
| 296 | - | if !rows.is_empty() { | |
| 297 | - | out.push(Node::list(rows)); | |
| 298 | - | } | |
| 299 | - | out | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | /// What is coming. | |
| 303 | - | fn due_this_week(data: &WeeklyReviewData) -> Vec<Node> { | |
| 304 | - | let mut out = vec![Node::section("Due This Week")]; | |
| 305 | - | if data.tasks_due_next_week.is_empty() { | |
| 306 | - | out.push(Node::empty("No tasks due this week")); | |
| 307 | - | } else { | |
| 308 | - | out.push(Node::list( | |
| 309 | - | data.tasks_due_next_week | |
| 310 | - | .iter() | |
| 311 | - | .map(|task| task_row(task).meta(task.due_formatted())), | |
| 312 | - | )); | |
| 313 | - | } | |
| 314 | - | out | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | /// The week's priorities. | |
| 318 | - | /// | |
| 319 | - | /// # The third finding, closed | |
| 320 | - | /// | |
| 321 | - | /// **A place awaiting content is a region, and `Slot` already had one.** | |
| 322 | - | /// | |
| 323 | - | /// Always three slots. A filled one holds a task; an empty one is a real | |
| 324 | - | /// described thing: it is reachable, it is named, and it is where a chosen task | |
| 325 | - | /// lands. Two filled slots and one empty slot is not a list of two tasks, and | |
| 326 | - | /// describing it as one loses that there is room for a third. | |
| 327 | - | /// | |
| 328 | - | /// So: three [`Slot`]s, one per priority, each [`named`](Slot::named). A region | |
| 329 | - | /// is exactly a named place, so this costs no vocabulary. It is heavy, three | |
| 330 | - | /// regions for three slots, and the weight sits in the description rather than | |
| 331 | - | /// in new words. | |
| 332 | - | /// | |
| 333 | - | /// Rejected with it: extending [`Node::StandIn`] to stand for a *place* rather | |
| 334 | - | /// than a missing item. `StandIn` exists to stop a fake row appearing per | |
| 335 | - | /// absence, and a place that is empty and can be landed on carries an identity | |
| 336 | - | /// and a target; one member covering both would blur what `StandIn` is for. It | |
| 337 | - | /// still says what is inside an empty region, which is nothing, and that is the | |
| 338 | - | /// job it has. | |
| 339 | - | /// | |
| 340 | - | /// The meter goes rather than standing beside them. Three named regions carry | |
| 341 | - | /// the count they were a summary of, and a bar reading "1 of 3" next to three | |
| 342 | - | /// places one of which is full is the same fact drawn twice. | |
| 343 | - | /// | |
| 344 | - | /// # More focused than there are slots | |
| 345 | - | /// | |
| 346 | - | /// `tasks.is_focus` is one column and nothing enforces [`FOCUS_SLOTS`], so a | |
| 347 | - | /// fourth focused task is possible and must not vanish into a description that | |
| 348 | - | /// only ever draws three. The count of places is the greater of the two, so | |
| 349 | - | /// overflow reads as a fourth priority rather than as a task that stopped | |
| 350 | - | /// existing. | |
| 351 | - | fn focus(data: &WeeklyReviewData, waits: &FocusBlockers, week: NaiveDate) -> Vec<Node> { | |
| 352 | - | let taken = data.focused_tasks.len(); | |
| 353 | - | let mut out = vec![Node::section("This Week's Focus")]; | |
| 354 | - | ||
| 355 | - | for index in 0..FOCUS_SLOTS.max(taken) { | |
| 356 | - | let place = Slot::group(format!("weekly-focus-{}", index + 1)) | |
| 357 | - | .named(format!("Priority {}", index + 1)); | |
| 358 | - | out.push(Node::Region(match data.focused_tasks.get(index) { | |
| 359 | - | Some(task) => place.with(Node::list([task_row(task).act(Act::new( | |
| 360 | - | "Remove", | |
| 361 | - | in_week( | |
| 362 | - | Action::post(format!("/weekly-review/focus/{}", task.id)) | |
| 363 | - | .with("focus", "false"), | |
| 364 | - | week, | |
| 365 | - | ), | |
| 366 | - | ))])), | |
| 367 | - | // Nothing about how a task gets here: the suggestions below carry | |
| 368 | - | // the only address that fills a place, and a browser's drop target | |
| 369 | - | // and a terminal's Enter are two renderers' answers to that one | |
| 370 | - | // fact rather than something to name once in words. | |
| 371 | - | None => place.with(Node::empty("Open")), | |
| 372 | - | })); | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | if taken > 0 { | |
| 376 | - | out.push(Node::act( | |
| 377 | - | "Clear all focus", | |
| 378 | - | in_week(Action::post("/weekly-review/focus/clear"), week), | |
| 379 | - | )); | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | // The suggestions are offered only while there is somewhere to put one, | |
| 383 | - | // which is `available.length > 0 && focused.length < 3` in the JS. The | |
| 384 | - | // repository already caps the candidates at 10, so unlike the lists above | |
| 385 | - | // this is a limit in the data rather than in a card. | |
| 386 | - | if taken < FOCUS_SLOTS && !data.available_for_focus.is_empty() { | |
| 387 | - | out.push(Node::Heading { | |
| 388 | - | level: makeover_layout::Heading::Subsection, | |
| 389 | - | text: "Suggested".to_owned(), | |
| 390 | - | }); | |
| 391 | - | out.push(Node::list(data.available_for_focus.iter().map(|task| { | |
| 392 | - | let mut row = task_row(task); | |
| 393 | - | if let Some(mark) = waiting_mark(task, waits) { | |
| 394 | - | row = row.token(mark); | |
| 395 | - | } | |
| 396 | - | row.act(Act::new( | |
| 397 | - | "Focus", | |
| 398 | - | in_week( | |
| 399 | - | Action::post(format!("/weekly-review/focus/{}", task.id)).with("focus", "true"), | |
| 400 | - | week, | |
| 401 | - | ), | |
| 402 | - | )) | |
| 403 | - | }))); | |
| 404 | - | } | |
| 405 | - | out | |
| 120 | + | /// The review stores its two answers as one string with the prompts written | |
| 121 | + | /// into it, and `weekly-review-render.js:renderReflection` picks them apart with | |
| 122 | + | /// two regexes. Read here rather than stored apart because the storage is the | |
| 123 | + | /// JS screen's too and it still ships: a described screen that wrote a second | |
| 124 | + | /// format would make the two disagree about a week they both open. | |
| 125 | + | fn prompt_answer(notes: &str, marker: &str, until: Option<&str>) -> String { | |
| 126 | + | let Some(start) = notes.find(marker) else { | |
| 127 | + | return String::new(); | |
| 128 | + | }; | |
| 129 | + | let rest = ¬es[start + marker.len()..]; | |
| 130 | + | let end = until.and_then(|next| rest.find(next)).unwrap_or(rest.len()); | |
| 131 | + | rest[..end].trim().to_owned() | |
| 406 | 132 | } | |
| 407 | 133 | ||
| 408 | 134 | /// What a focus candidate waits on, as one token. | |
| @@ -433,185 +159,608 @@ | |||
| 433 | 159 | Some(Tag::badge(label).tone(Tone::Warning)) | |
| 434 | 160 | } | |
| 435 | 161 | ||
| 436 | - | /// How each project is doing. | |
| 437 | - | fn projects_health(health: &[ProjectHealth]) -> Vec<Node> { | |
| 438 | - | if health.is_empty() { | |
| 439 | - | return Vec::new(); | |
| 162 | + | declare! { | |
| 163 | + | /// One task, the way every list on this screen writes one. | |
| 164 | + | /// | |
| 165 | + | /// The project is `meta` rather than a token: it is a plain trailing fact | |
| 166 | + | /// with no tone of its own and no click to answer, which is the line | |
| 167 | + | /// [`Row::tokens`](quasi_router::screen::Row::tokens) draws. | |
| 168 | + | shape task_row(task: &Task) -> Row; | |
| 169 | + | ||
| 170 | + | row &task.title { | |
| 171 | + | for project in task.project_name.iter() { | |
| 172 | + | meta project; | |
| 173 | + | } | |
| 440 | 174 | } | |
| 441 | - | ||
| 442 | - | vec![ | |
| 443 | - | Node::section("Projects Health"), | |
| 444 | - | Node::list(health.iter().map(|project| { | |
| 445 | - | let mut row = Row::new(&project.name) | |
| 446 | - | .meta(format!( | |
| 447 | - | "{} active, {} total", | |
| 448 | - | project.active_count, project.total_count | |
| 449 | - | )) | |
| 450 | - | .token(Tag::badge(&project.status).tone(health_tone(&project.status))); | |
| 451 | - | if project.overdue_count > 0 { | |
| 452 | - | row = row.token( | |
| 453 | - | Tag::badge(format!("{} overdue", project.overdue_count)) | |
| 454 | - | .tone(makeover_layout::Tone::Danger), | |
| 455 | - | ); | |
| 456 | - | } | |
| 457 | - | row | |
| 458 | - | })), | |
| 459 | - | ] | |
| 460 | 175 | } | |
| 461 | 176 | ||
| 462 | - | /// The days marked off. | |
| 463 | - | /// | |
| 464 | - | /// The one section here where the vocabulary already had the answer and the | |
| 465 | - | /// port did not have to argue for it. Seven independently latched things, each | |
| 466 | - | /// answering a click, is [`Tag::chip`] with | |
| 467 | - | /// [`latched`](quasi_router::screen::Tag::latched) — which arrived for filter | |
| 468 | - | /// chips and turns out to describe this without a change. | |
| 469 | - | /// | |
| 470 | - | /// Not one control picking one of a set: days off are seven yes-or-no answers | |
| 471 | - | /// where any number can be yes. | |
| 472 | - | fn days_off(data: &WeeklyReviewData, week: NaiveDate) -> Vec<Node> { | |
| 473 | - | let mut out = vec![Node::section("Days Off")]; | |
| 474 | - | out.extend(WEEKDAYS.iter().enumerate().map(|(index, name)| { | |
| 475 | - | let off = data | |
| 476 | - | .vacation_days | |
| 477 | - | .contains(&u8::try_from(index).unwrap_or(0)); | |
| 478 | - | Node::Token( | |
| 479 | - | Tag::chip( | |
| 480 | - | *name, | |
| 481 | - | in_week( | |
| 482 | - | Action::post(format!("/weekly-review/vacation/{index}")), | |
| 483 | - | week, | |
| 484 | - | ), | |
| 485 | - | ) | |
| 486 | - | .latched(off), | |
| 487 | - | ) | |
| 488 | - | })); | |
| 489 | - | out | |
| 177 | + | /// One place in the week's focus: filled, or open. | |
| 178 | + | struct Place { | |
| 179 | + | /// Which priority it is, from one. | |
| 180 | + | number: usize, | |
| 181 | + | /// What is in it, if anything. | |
| 182 | + | task: Option<Task>, | |
| 490 | 183 | } | |
| 491 | 184 | ||
| 492 | - | /// Pull one prompt's answer back out of the stored notes. | |
| 493 | - | /// | |
| 494 | - | /// The review stores its two answers as one string with the prompts written | |
| 495 | - | /// into it, and `weekly-review-render.js:renderReflection` picks them apart with | |
| 496 | - | /// two regexes. Read here rather than stored apart because the storage is the | |
| 497 | - | /// JS screen's too and it still ships: a described screen that wrote a second | |
| 498 | - | /// format would make the two disagree about a week they both open. | |
| 499 | - | fn prompt_answer(notes: &str, marker: &str, until: Option<&str>) -> String { | |
| 500 | - | let Some(start) = notes.find(marker) else { | |
| 501 | - | return String::new(); | |
| 502 | - | }; | |
| 503 | - | let rest = ¬es[start + marker.len()..]; | |
| 504 | - | let end = until.and_then(|next| rest.find(next)).unwrap_or(rest.len()); | |
| 505 | - | rest[..end].trim().to_owned() | |
| 185 | + | /// Whether the place holds a task. | |
| 186 | + | fn filled(place: &Place) -> bool { | |
| 187 | + | place.task.is_some() | |
| 506 | 188 | } | |
| 507 | 189 | ||
| 508 | - | /// The reflection. | |
| 509 | - | /// | |
| 510 | - | /// # The fourth finding | |
| 511 | - | /// | |
| 512 | - | /// **A field cannot say its value is a draft.** | |
| 513 | - | /// | |
| 514 | - | /// `weekly-review.js` keeps what the user has typed in `localStorage` against | |
| 515 | - | /// the week, restores it over the stored notes on render, and clears it on | |
| 516 | - | /// completion, so a review survives closing the app halfway through writing it. | |
| 517 | - | /// Nothing in [`Field`] can say that: `value` is what the field holds, and | |
| 518 | - | /// whether the host should be keeping unsent keystrokes somewhere is not a | |
| 519 | - | /// property of the value. | |
| 520 | - | /// | |
| 521 | - | /// The described screen therefore loses the draft and shows what is stored, | |
| 522 | - | /// which is correct and worse. Filed against quasicoherent rather than worked | |
| 523 | - | /// around, because the workaround is a route that writes on every keystroke and | |
| 524 | - | /// that is a different feature wearing this one's name. | |
| 525 | - | /// | |
| 526 | - | /// The prompts themselves are the JS's, verbatim, including the placeholders: | |
| 527 | - | /// they are the question being asked and not decoration. | |
| 528 | - | fn reflection(data: &WeeklyReviewData, week: NaiveDate) -> Vec<Node> { | |
| 529 | - | let went_well = prompt_answer( | |
| 530 | - | &data.notes, | |
| 531 | - | "What went well:", | |
| 532 | - | Some("What could be improved:"), | |
| 533 | - | ); | |
| 534 | - | let improve = prompt_answer(&data.notes, "What could be improved:", None); | |
| 535 | - | ||
| 536 | - | let field = |name: &str, label: &str, placeholder: &str, value: String| { | |
| 537 | - | let mut field = Field::new(makeover_layout::FieldKind::Textarea, name, label).value(value); | |
| 538 | - | field.placeholder = Some(placeholder.to_owned()); | |
| 539 | - | field | |
| 540 | - | }; | |
| 541 | - | ||
| 542 | - | vec![ | |
| 543 | - | Node::section("Reflection"), | |
| 544 | - | Node::Form { | |
| 545 | - | action: in_week(Action::post("/weekly-review/complete"), week), | |
| 546 | - | submit: if data.is_completed { | |
| 547 | - | "Save notes".to_owned() | |
| 548 | - | } else { | |
| 549 | - | "Complete review".to_owned() | |
| 550 | - | }, | |
| 551 | - | fields: vec![ | |
| 552 | - | field( | |
| 553 | - | "went-well", | |
| 554 | - | "What went well?", | |
| 555 | - | "Completed the budget ahead of schedule...", | |
| 556 | - | went_well, | |
| 557 | - | ), | |
| 558 | - | field( | |
| 559 | - | "improve", | |
| 560 | - | "What could be improved?", | |
| 561 | - | "Need to block more focus time...", | |
| 562 | - | improve, | |
| 563 | - | ), | |
| 564 | - | ], | |
| 565 | - | }, | |
| 566 | - | ] | |
| 190 | + | /// One weekday, and whether it is marked off. | |
| 191 | + | struct Weekday { | |
| 192 | + | /// Its index, Monday first, which is what the route takes. | |
| 193 | + | index: usize, | |
| 194 | + | name: &'static str, | |
| 195 | + | off: bool, | |
| 567 | 196 | } | |
| 568 | 197 | ||
| 569 | - | /// The whole screen. | |
| 570 | - | /// | |
| 571 | - | /// Built here rather than inside each route for the reason the projects screen | |
| 572 | - | /// gives: a write lands in more than one section — focusing a task changes the | |
| 573 | - | /// focus list, the suggestions and the meter — and a `Response` names one | |
| 574 | - | /// region. | |
| 575 | - | fn screen(state: &AppState, week: NaiveDate) -> Result<Screen, RouteError> { | |
| 198 | + | /// Everything the review draws, read once. | |
| 199 | + | struct Review { | |
| 200 | + | data: WeeklyReviewData, | |
| 201 | + | week: NaiveDate, | |
| 202 | + | waits: FocusBlockers, | |
| 203 | + | /// Three places, or more when more tasks are focused than there are slots. | |
| 204 | + | /// | |
| 205 | + | /// `tasks.is_focus` is one column and nothing enforces [`FOCUS_SLOTS`], so | |
| 206 | + | /// a fourth focused task is possible and must not vanish into a description | |
| 207 | + | /// that only ever draws three. The count of places is the greater of the | |
| 208 | + | /// two, so overflow reads as a fourth priority rather than as a task that |
Lines truncated
| @@ -28,8 +28,10 @@ | |||
| 28 | 28 | use goingson_core::{ | |
| 29 | 29 | Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus, | |
| 30 | 30 | }; | |
| 31 | - | use quasi_router::screen::{Act, Choice, Field, Meter, Row, Tag}; | |
| 32 | - | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 31 | + | use makeover_layout::Tone; | |
| 32 | + | use quasi_declare::declare; | |
| 33 | + | use quasi_router::screen::{Choice, Meter, Tag}; | |
| 34 | + | use quasi_router::{Action, Node, Response, RouteError, Router}; | |
| 33 | 35 | ||
| 34 | 36 | use super::{filtered_by, project_id, status_tone, type_label}; | |
| 35 | 37 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -37,74 +39,131 @@ | |||
| 37 | 39 | #[cfg(test)] | |
| 38 | 40 | mod tests; | |
| 39 | 41 | ||
| 40 | - | /// One linked task. | |
| 41 | - | /// | |
| 42 | - | /// The subtask bar is `RowPart::Proportion` rather than a `Node::Meter`: a row | |
| 43 | - | /// holds no nodes, so it carries the description of a bar instead. | |
| 44 | - | fn task_row(task: &Task) -> Row { | |
| 45 | - | let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str())); | |
| 46 | - | // Whether the task is available; see [`crate::quasi::Availability`] for why | |
| 47 | - | // it is shared rather than redrawn. | |
| 48 | - | if let Some(marker) = crate::quasi::Availability::of(task).marker() { | |
| 49 | - | row = row.token(marker); | |
| 50 | - | } | |
| 51 | - | if task.subtask_count() > 0 { | |
| 52 | - | row = row.meter( | |
| 53 | - | Meter::new( | |
| 54 | - | u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX), | |
| 55 | - | u32::try_from(task.subtask_count()).unwrap_or(u32::MAX), | |
| 56 | - | ) | |
| 57 | - | .label("subtasks"), | |
| 58 | - | ); | |
| 59 | - | } | |
| 60 | - | if task.due.is_some() { | |
| 61 | - | row = row.token(Tag::badge(task.due_formatted())); | |
| 62 | - | } | |
| 63 | - | row.activate(Action::get(format!("/tasks/{}", task.id))) | |
| 42 | + | /// A count as a meter reads it. Never negative, never overflowing. | |
| 43 | + | fn counted(n: usize) -> u32 { | |
| 44 | + | u32::try_from(n).unwrap_or(u32::MAX) | |
| 64 | 45 | } | |
| 65 | 46 | ||
| 66 | - | /// The tasks column. | |
| 47 | + | /// Whether the task has subtasks to show progress over. | |
| 48 | + | fn has_subtasks(task: &Task) -> bool { | |
| 49 | + | task.subtask_count() > 0 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | declare! { | |
| 53 | + | /// One linked task. | |
| 54 | + | /// | |
| 55 | + | /// The subtask bar is `RowPart::Proportion` rather than a `Node::Meter`: a | |
| 56 | + | /// row holds no nodes, so it carries the description of a bar instead. | |
| 57 | + | /// | |
| 58 | + | /// Whether the task is available is [`crate::quasi::Availability`]'s, which | |
| 59 | + | /// is shared rather than redrawn. | |
| 60 | + | shape task_row(task: &Task) -> Row; | |
| 61 | + | ||
| 62 | + | row &task.title { | |
| 63 | + | token Tag::badge(task.priority.as_str()); | |
| 64 | + | ||
| 65 | + | for marker in crate::quasi::Availability::of(task).marker().into_iter() { | |
| 66 | + | token marker; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | meter Meter::new(counted(task.subtasks_completed()), counted(task.subtask_count())) | |
| 70 | + | .label("subtasks") | |
| 71 | + | when has_subtasks(task); | |
| 72 | + | ||
| 73 | + | token Tag::badge(task.due_formatted()) when task.due.is_some(); | |
| 74 | + | ||
| 75 | + | activate to get "/tasks/{task.id}"; | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Whether every linked task is finished. | |
| 67 | 80 | /// | |
| 68 | 81 | /// Three states rather than two, which is the JS's own distinction and worth | |
| 69 | 82 | /// keeping: nothing linked yet is a different thing from everything being done. | |
| 70 | - | fn tasks_column(tasks: &[Task]) -> Node { | |
| 71 | - | if tasks.is_empty() { | |
| 72 | - | return Node::empty("No tasks linked yet."); | |
| 73 | - | } | |
| 74 | - | if tasks | |
| 75 | - | .iter() | |
| 76 | - | .all(|task| task.status == TaskStatus::Completed) | |
| 77 | - | { | |
| 78 | - | return Node::empty("All tasks complete."); | |
| 79 | - | } | |
| 80 | - | Node::list(tasks.iter().map(task_row)) | |
| 83 | + | fn all_done(tasks: &[Task]) -> bool { | |
| 84 | + | !tasks.is_empty() | |
| 85 | + | && tasks | |
| 86 | + | .iter() | |
| 87 | + | .all(|task| task.status == TaskStatus::Completed) | |
| 81 | 88 | } | |
| 82 | 89 | ||
| 83 | - | /// The events column. | |
| 84 | - | fn events_column(events: &[Event]) -> Node { | |
| 85 | - | if events.is_empty() { | |
| 86 | - | return Node::empty("No events linked yet."); | |
| 87 | - | } | |
| 88 | - | Node::list(events.iter().map(|event| { | |
| 89 | - | let at = event.start_time.with_timezone(&Local); | |
| 90 | - | Row::new(&event.title).meta(at.format("%b %-d, %-I:%M %p").to_string()) | |
| 91 | - | })) | |
| 92 | - | } | |
| 90 | + | declare! { | |
| 91 | + | /// The tasks column. | |
| 92 | + | shape tasks_column(tasks: &[Task]) -> Node; | |
| 93 | 93 | ||
| 94 | - | /// The emails column. | |
| 95 | - | fn emails_column(emails: &[Email]) -> Node { | |
| 96 | - | if emails.is_empty() { | |
| 97 | - | return Node::empty("No emails linked yet."); | |
| 98 | - | } | |
| 99 | - | Node::list(emails.iter().map(|email| { | |
| 100 | - | let mut row = Row::new(&email.subject) | |
| 101 | - | .secondary(&email.from) | |
| 102 | - | .meta(email.received_formatted()); | |
| 103 | - | if !email.is_read { | |
| 104 | - | row = row.token(Tag::badge("Unread").tone(makeover_layout::Tone::Info)); | |
| 94 | + | given tasks_state(tasks) { | |
| 95 | + | Showing::Nothing -> empty "No tasks linked yet."; | |
| 96 | + | Showing::Done -> empty "All tasks complete."; | |
| 97 | + | otherwise -> list { | |
| 98 | + | for task in tasks.iter() { | |
| 99 | + | include task_row(task); | |
| 100 | + | } | |
| 105 | 101 | } | |
| 106 | - | row | |
| 107 | - | })) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// What a column has to show. | |
| 106 | + | enum Showing { | |
| 107 | + | /// Nothing linked yet. | |
| 108 | + | Nothing, | |
| 109 | + | /// Linked, and all of it finished. | |
| 110 | + | Done, | |
| 111 | + | /// Rows. | |
| 112 | + | Rows, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Which of the three the tasks column is in. | |
| 116 | + | fn tasks_state(tasks: &[Task]) -> Showing { | |
| 117 | + | if tasks.is_empty() { | |
| 118 | + | Showing::Nothing | |
| 119 | + | } else if all_done(tasks) { | |
| 120 | + | Showing::Done | |
| 121 | + | } else { | |
| 122 | + | Showing::Rows | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// When an event starts, as the column reads it. | |
| 127 | + | fn event_at(event: &Event) -> String { | |
| 128 | + | event | |
| 129 | + | .start_time | |
| 130 | + | .with_timezone(&Local) | |
| 131 | + | .format("%b %-d, %-I:%M %p") | |
| 132 | + | .to_string() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | declare! { | |
| 136 | + | /// The events column. | |
| 137 | + | shape events_column(events: &[Event]) -> Node; | |
| 138 | + | ||
| 139 | + | given events.is_empty() { | |
| 140 | + | true -> empty "No events linked yet."; | |
| 141 | + | otherwise -> list { | |
| 142 | + | for event in events.iter() { | |
| 143 | + | row &event.title { | |
| 144 | + | meta event_at(event); | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | declare! { | |
| 152 | + | /// The emails column. | |
| 153 | + | shape emails_column(emails: &[Email]) -> Node; | |
| 154 | + | ||
| 155 | + | given emails.is_empty() { | |
| 156 | + | true -> empty "No emails linked yet."; | |
| 157 | + | otherwise -> list { | |
| 158 | + | for email in emails.iter() { | |
| 159 | + | row &email.subject { | |
| 160 | + | secondary &email.from; | |
| 161 | + | meta email.received_formatted(); | |
| 162 | + | token Tag::badge("Unread").tone(Tone::Info) unless email.is_read; | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 108 | 167 | } | |
| 109 | 168 | ||
| 110 | 169 | /// A file size, in the largest unit that keeps it above 1. | |
| @@ -123,79 +182,105 @@ | |||
| 123 | 182 | } | |
| 124 | 183 | } | |
| 125 | 184 | ||
| 126 | - | /// The attachments column, both its controls included. | |
| 127 | - | /// | |
| 128 | - | /// # The finding this port turned up, and how it closed | |
| 129 | - | /// | |
| 130 | - | /// **An action that opens a native dialog is not a route, and `Destination` had | |
| 131 | - | /// nowhere to put it.** Attaching opens the OS file picker; opening hands the | |
| 132 | - | /// blob to the OS. They are two answers rather than one member: | |
| 133 | - | /// | |
| 134 | - | /// - *Opening* is a one-way handoff and needed no new API. The route spools the | |
| 135 | - | /// blob out under its own filename and answers | |
| 136 | - | /// `Response::goto(Action::external("file://…"))`, which the webview host | |
| 137 | - | /// sends as `HX-Redirect` and every other host reads as "leave". | |
| 138 | - | /// - *Picking* returns a value into a write, which is a form concern, so it is | |
| 139 | - | /// `FieldKind::File` in makeover-layout 0.11.0 — a native picker in Tauri, an | |
| 140 | - | /// `<input type="file">` on a server, a path prompt in a terminal. | |
| 141 | - | /// | |
| 142 | - | /// # Where the bytes go | |
| 143 | - | /// | |
| 144 | - | /// Picking a file says nothing about where the bytes go, so this column needs | |
| 145 | - | /// `POST /projects/{id}/attachments`. The handler is [`attach`], and the work | |
| 146 | - | /// is | |
| 147 | - | /// `commands::attachment::attach_path`, lifted out of the Tauri command so | |
| 148 | - | /// there is one copy of the hashing, the dedup and the size limit rather than | |
| 149 | - | /// two. | |
| 150 | - | /// | |
| 151 | - | /// # The transport half is the host's, and the field could not carry it | |
| 152 | - | /// | |
| 153 | - | /// **Not a `FieldKind::File`.** That renders `<input type="file">` into the | |
| 154 | - | /// same webview a browser would use, htmx submits it urlencoded, and a browser | |
| 155 | - | /// reports a masked filename; multipart is refused outright by | |
| 156 | - | /// `quasi_http::is_form`. So the field would deliver neither bytes nor a path. | |
| 157 | - | /// | |
| 158 | - | /// It is an [`Act`] carrying [`Action::by_host`]: the host makes this call and | |
| 159 | - | /// the renderer does not. `frontend/js/host.js` opens the native dialog and | |
| 160 | - | /// posts the path here, and a path under `file` is what the route reads. | |
| 161 | - | /// | |
| 162 | - | /// `error` is what a refused attach carries back, as a notice beside the | |
| 163 | - | /// control rather than on it, since there is no field to hang it on. | |
| 164 | - | fn attachments_column(project: ProjectId, attachments: &[Attachment], error: Option<&str>) -> Slot { | |
| 165 | - | let body = if attachments.is_empty() { | |
| 166 | - | Node::empty("No attachments yet.") | |
| 167 | - | } else { | |
| 168 | - | Node::list(attachments.iter().map(|file| { | |
| 169 | - | Row::new(&file.filename) | |
| 170 | - | .meta(file_size(file.file_size)) | |
| 171 | - | .act(Act::new( | |
| 172 | - | "Open", | |
| 173 | - | Action::get(format!("/projects/{project}/attachments/{}/open", file.id)), | |
| 174 | - | )) | |
| 175 | - | })) | |
| 176 | - | }; | |
| 177 | - | ||
| 178 | - | Slot::new("dashboard-attachments", RegionKind::Pane) | |
| 179 | - | .with(Node::section("Attachments")) | |
| 180 | - | .with(body) | |
| 181 | - | // A host-performed act rather than a `FieldKind::File` in a form. See | |
| 182 | - | // the header: the field could not deliver a file on this host, because | |
| 183 | - | // it renders `<input type="file">` and a browser reports a masked | |
| 184 | - | // filename rather than a path. `Action::by_host` is the ruled answer | |
| 185 | - | // (`a81384d4`), and `frontend/js/host.js` is the host half. | |
| 186 | - | .with(Node::Act(Act::new( | |
| 187 | - | "Attach a file", | |
| 188 | - | Action::post(format!("/projects/{project}/attachments")) | |
| 189 | - | .by_host() | |
| 190 | - | .awaiting(), | |
| 191 | - | ))) | |
| 192 | - | .with(match error { | |
| 193 | - | Some(message) => Node::banner(makeover_layout::Tone::Danger, message), | |
| 194 | - | None => Node::text(""), | |
| 195 | - | }) | |
| 185 | + | /// The attachments column's contents, read once. | |
| 186 | + | struct Attached { | |
| 187 | + | project: ProjectId, | |
| 188 | + | files: Vec<Attachment>, | |
| 189 | + | /// What a refused attach carries back, as a notice beside the control | |
| 190 | + | /// rather than on it, since there is no field to hang it on. | |
| 191 | + | error: Option<String>, | |
| 196 | 192 | } | |
| 197 | 193 | ||
| 198 | - | /// How far along one milestone is, and how that reads. | |
| 194 | + | /// Read the attachments. | |
| 195 | + | fn attached( | |
| 196 | + | state: &AppState, | |
| 197 | + | project: ProjectId, | |
| 198 | + | error: Option<&str>, | |
| 199 | + | ) -> Result<Attached, RouteError> { | |
| 200 | + | Ok(Attached { | |
| 201 | + | project, | |
| 202 | + | files: state | |
| 203 | + | .attachments | |
| 204 | + | .list_for_project(project, DESKTOP_USER_ID) | |
| 205 | + | .map_err(|error| RouteError::internal(error.to_string()))?, | |
| 206 | + | error: error.map(ToOwned::to_owned), | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Whether the attach was refused. | |
| 211 | + | fn refused(attached: &Attached) -> bool { | |
| 212 | + | attached.error.is_some() | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// Why it was. | |
| 216 | + | fn refusal(attached: &Attached) -> String { | |
| 217 | + | attached.error.clone().unwrap_or_default() | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | declare! { | |
| 221 | + | /// The attachments column, both its controls included. | |
| 222 | + | /// | |
| 223 | + | /// # The finding this port turned up, and how it closed | |
| 224 | + | /// | |
| 225 | + | /// **An action that opens a native dialog is not a route, and `Destination` | |
| 226 | + | /// had nowhere to put it.** Attaching opens the OS file picker; opening | |
| 227 | + | /// hands the blob to the OS. They are two answers rather than one member: | |
| 228 | + | /// | |
| 229 | + | /// - *Opening* is a one-way handoff and needed no new API. The route spools | |
| 230 | + | /// the blob out under its own filename and answers | |
| 231 | + | /// `Response::goto(Action::external("file://…"))`, which the webview host | |
| 232 | + | /// sends as `HX-Redirect` and every other host reads as "leave". | |
| 233 | + | /// - *Picking* returns a value into a write, which is a form concern, so it | |
| 234 | + | /// is `FieldKind::File` in makeover-layout 0.11.0 -- a native picker in | |
| 235 | + | /// Tauri, an `<input type="file">` on a server, a path prompt in a | |
| 236 | + | /// terminal. | |
| 237 | + | /// | |
| 238 | + | /// # Where the bytes go | |
| 239 | + | /// | |
| 240 | + | /// Picking a file says nothing about where the bytes go, so this column | |
| 241 | + | /// needs `POST /projects/{id}/attachments`. The handler is [`attach`], and | |
| 242 | + | /// the work is `commands::attachment::attach_path`, lifted out of the Tauri | |
| 243 | + | /// command so there is one copy of the hashing, the dedup and the size limit | |
| 244 | + | /// rather than two. | |
| 245 | + | /// | |
| 246 | + | /// # The transport half is the host's, and the field could not carry it | |
| 247 | + | /// | |
| 248 | + | /// **Not a `FieldKind::File`.** That renders `<input type="file">` into the | |
| 249 | + | /// same webview a browser would use, htmx submits it urlencoded, and a | |
| 250 | + | /// browser reports a masked filename; multipart is refused outright by | |
| 251 | + | /// `quasi_http::is_form`. So the field would deliver neither bytes nor a | |
| 252 | + | /// path. | |
| 253 | + | /// | |
| 254 | + | /// It is an [`Act`] carrying [`Action::by_host`]: the host makes this call | |
| 255 | + | /// and the renderer does not. `frontend/js/host.js` opens the native dialog | |
| 256 | + | /// and posts the path here, and a path under `file` is what the route | |
| 257 | + | /// reads. `a81384d4` is the ruling. | |
| 258 | + | shape attachments_column(attached: &Attached) -> Slot; | |
| 259 | + | ||
| 260 | + | region "dashboard-attachments" as Pane { | |
| 261 | + | section "Attachments"; | |
| 262 | + | ||
| 263 | + | empty "No attachments yet." when attached.files.is_empty(); | |
| 264 | + | ||
| 265 | + | list { | |
| 266 | + | for file in attached.files.iter() { | |
| 267 | + | row &file.filename { | |
| 268 | + | meta file_size(file.file_size); | |
| 269 | + | act "Open" | |
| 270 | + | to get "/projects/{attached.project}/attachments/{file.id}/open"; | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | } unless attached.files.is_empty(); | |
| 274 | + | ||
| 275 | + | act "Attach a file" to post "/projects/{attached.project}/attachments" | |
| 276 | + | by_host awaiting; | |
| 277 | + | ||
| 278 | + | banner Tone::Danger refusal(attached) when refused(attached); | |
| 279 | + | text "" unless refused(attached); | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// How far along one milestone is. | |
| 199 | 284 | /// | |
| 200 | 285 | /// `list_milestones` computes the same three numbers in the command layer. Not | |
| 201 | 286 | /// shared, because sharing it would mean lifting `MilestoneResponse` out of the | |
| @@ -214,164 +299,192 @@ | |||
| 214 | 299 | (done, mine.len()) | |
| 215 | 300 | } | |
| 216 | 301 | ||
| 217 | - | /// One open milestone, with the controls that act on it. | |
| 218 | - | /// | |
| 219 | - | /// Reordering is two acts rather than a drag. The act that would go off the end | |
| 220 | - | /// is `Act::disabled` rather than hidden: a control that vanishes at the edge | |
| 221 | - | /// of a list is a control the user has to discover twice. | |
| 222 | - | fn milestone_row( | |
| 223 | - | project: ProjectId, | |
| 224 | - | milestone: &Milestone, | |
| 225 | - | tasks: &[Task], | |
| 226 | - | at: usize, | |
| 227 | - | of: usize, | |
| 228 | - | ) -> Row { | |
| 229 | - | let (done, total) = milestone_progress(tasks, milestone); | |
| 230 | - | // A bar rather than "3/7" in the meta slot, as of `da5666ae`. The ratio is | |
| 231 | - | // still readable: `meter_html` writes both numbers into the accessible name, | |
| 232 | - | // which is what the concatenated text was for. | |
| 233 | - | let mut row = Row::new(&milestone.name).meter( | |
| 234 | - | Meter::new( | |
| 235 | - | u32::try_from(done).unwrap_or(u32::MAX), | |
| 236 | - | u32::try_from(total).unwrap_or(u32::MAX), | |
| 237 | - | ) | |
| 238 | - | .label("tasks"), | |
| 239 | - | ); | |
| 240 | - | ||
| 241 | - | if let Some(date) = milestone.target_date { | |
| 242 | - | row = row.token(Tag::badge(date.format("%Y-%m-%d").to_string())); | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | let move_by = |by: i8| { | |
| 246 | - | Action::post(format!( | |
| 247 | - | "/projects/{project}/milestones/{}/move", | |
| 248 | - | milestone.id | |
| 249 | - | )) | |
| 250 | - | .with("by", by.to_string()) | |
| 251 | - | }; | |
| 252 | - | let up = Act::new("Move up", move_by(-1)); | |
| 253 | - | let down = Act::new("Move down", move_by(1)); | |
| 254 | - | ||
| 255 | - | row.act(Act::new( | |
| 256 | - | "Edit", | |
| 257 | - | Action::get(format!( | |
| 258 | - | "/projects/{project}/milestones/{}/edit", | |
| 259 | - | milestone.id | |
| 260 | - | )), | |
| 261 | - | )) | |
| 262 | - | .act(if at == 0 { up.disabled() } else { up }) | |
| 263 | - | .act(if at + 1 == of { down.disabled() } else { down }) | |
| 264 | - | .act( | |
| 265 | - | Act::new( | |
| 266 | - | "Delete", | |
| 267 | - | Action::post(format!( | |
| 268 | - | "/projects/{project}/milestones/{}/delete", | |
| 269 | - | milestone.id | |
| 270 | - | )), | |
| 271 | - | ) | |
| 272 | - | .tone(makeover_layout::Tone::Danger), | |
| 273 | - | ) | |
| 302 | + | /// One open milestone, with where it sits in the order. | |
| 303 | + | struct Standing { | |
| 304 | + | milestone: Milestone, | |
| 305 | + | /// How far along it is. | |
| 306 | + | /// | |
| 307 | + | /// A bar rather than "3/7" in the meta slot, as of `da5666ae`. The ratio is | |
| 308 | + | /// still readable: `meter_html` writes both numbers into the accessible | |
| 309 | + | /// name, which is what the concatenated text was for. | |
| 310 | + | progress: Meter, | |
| 311 | + | /// Whether it is already at the top of the list. | |
| 312 | + | first: bool, | |
| 313 | + | /// Whether it is already at the bottom. | |
| 314 | + | last: bool, | |
| 315 | + | /// The date it is aimed at, if it has one. | |
| 316 | + | target: Option<String>, | |
| 274 | 317 | } | |
| 275 | 318 | ||
| 276 | - | /// The milestones section. | |
| 277 | - | /// | |
| 278 | - | /// Add and edit are addresses rather than modals, on the shape the task | |
| 279 | - | /// overview established: an edit form is a screen of its own rather than a | |
| 280 | - | /// control on the screen it edits. That is why they were absent when this | |
| 281 | - | /// screen first landed, and it is the same reason they are here now — the | |
| 282 | - | /// forms exist as `/projects/{id}/milestones/new` and | |
| 283 | - | /// `/projects/{id}/milestones/{milestone}/edit`, so the controls are links. | |
| 284 | - | fn milestones( | |
| 319 | + | /// The milestones section, read once. | |
| 320 | + | struct Milestones { | |
| 321 | + | project: ProjectId, | |
| 322 | + | open: Vec<Standing>, | |
| 323 | + | done: Vec<Milestone>, | |
| 324 | + | /// Whether the finished ones are on screen. | |
| 325 | + | show_completed: bool, | |
| 326 | + | /// Whether the project has no milestones at all, which is a different thing | |
| 327 | + | /// from having none open. | |
| 328 | + | bare: bool, | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// Work out the milestones section. | |
| 332 | + | fn milestones_of( | |
| 285 | 333 | project: ProjectId, | |
| 286 | 334 | all: &[Milestone], | |
| 287 | 335 | tasks: &[Task], | |
| 288 | 336 | show_completed: bool, | |
| 289 | - | ) -> Vec<Node> { | |
| 290 | - | let (open, done): (Vec<&Milestone>, Vec<&Milestone>) = all | |
| 337 | + | ) -> Milestones { | |
| 338 | + | let (open, done): (Vec<Milestone>, Vec<Milestone>) = all | |
| 291 | 339 | .iter() | |
| 340 | + | .cloned() | |
| 292 | 341 | .partition(|milestone| milestone.status != MilestoneStatus::Completed); | |
| 293 | 342 | ||
| 294 | - | let add = Node::act( | |
| 295 | - | "New milestone".to_owned(), | |
| 296 | - | Action::get(format!("/projects/{project}/milestones/new")), | |
| 297 | - | ); | |
| 298 | - | ||
| 299 | - | let mut out = vec![Node::section("Milestones")]; | |
| 300 | - | if all.is_empty() { | |
| 301 | - | out.push(Node::empty("No milestones yet")); | |
| 302 | - | out.push(add); | |
| 303 | - | return out; |
Lines truncated