Skip to main content

max / goingson

Declare the weekly review, the calendar, the task list and the dashboard Forty-five declarations across four files, goingson from declared 67 to 101, which is over half its population. Zero productions: four files, three of them among the largest on the screen, and the form said all of it as written. Every wave before this one earned at least one. Eight shapes are deleted rather than declared, beating wave 12's six. weekly_review's focus slots and days-off chips became data in the read. task_list's description_cell and columns were counted-but-not-work suppliers the form can now write out in place, so the parked census shrinks for the first time, 53 to 51. events' form_fields and form_pane folded into one declaration, as did dashboard's milestone pair. A mutation returning its own type is free, and that is how a view moves. task_list's filters each need "the same view with one thing changed", which the form cannot spell because a `Type { .. }` aggregate is the hard limit. Seven View -> View methods answer it and progress.py counts none of them, where seven -> Action suppliers would have been seven permanent entries in the census. TZ_KINDS and BLOCK_TYPES were slices of tuples and are structs; COLUMNS was one and is the table's own body now. A tuple field is not reachable from a hole at all, since a step is `.` then an ident. weekly_review's overdue rows place two `meta` settings and the second wins, exactly as time_tracking's row does. Reproduced rather than repaired, and folded into the same reported problem. 920 lib tests pass, clippy clean over all targets.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 18:27 UTC
Signed with PGP, not checked
Commit: 3531d5dbe84742204d2be2fc289ca80ff58d6f50
Parent: 34d047b
5 files changed, +793 insertions, -1052 deletions
M Cargo.lock +1 -1
@@ -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 = &notes[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 = &notes[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