Skip to main content

max / goingson

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