Skip to main content

max / goingson

38.4 KB · 1146 lines History Blame Raw
1 //! The project dashboard, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! What a project has linked to it, in four columns, plus its milestones.
6 //!
7 //! # The shape
8 //!
9 //! - `GET /projects/{id}/dashboard` — the whole thing.
10 //! - `GET /projects/{id}/milestones/new` — the add form.
11 //! - `POST /projects/{id}/milestones` — create one.
12 //! - `GET /projects/{id}/milestones/{milestone}/edit` — the edit form.
13 //! - `POST /projects/{id}/milestones/{milestone}` — save one.
14 //! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`.
15 //! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one.
16 //! - `POST /projects/{id}/attachments` — attach the picked file.
17 //! - `GET /projects/{id}/attachments/{attachment}/open` — hand one to the host.
18 //!
19 //! Showing completed milestones is `?completed=1`, per decision 2, so the
20 //! expanded dashboard is reachable by address.
21 //!
22 //! The file picker and the handoff to the OS are both described; see
23 //! [`attachments_column`].
24
25 #![allow(clippy::needless_pass_by_value)]
26
27 use chrono::Local;
28 use goingson_core::{
29 Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
30 };
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};
35
36 use super::{filtered_by, project_id, status_tone, type_label};
37 use crate::state::{AppState, DESKTOP_USER_ID};
38
39 #[cfg(test)]
40 mod tests;
41
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)
45 }
46
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.
80 ///
81 /// Three states rather than two, which is the JS's own distinction and worth
82 /// keeping: nothing linked yet is a different thing from everything being done.
83 fn all_done(tasks: &[Task]) -> bool {
84 !tasks.is_empty()
85 && tasks
86 .iter()
87 .all(|task| task.status == TaskStatus::Completed)
88 }
89
90 declare! {
91 /// The tasks column.
92 shape tasks_column(tasks: &[Task]) -> Node;
93
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 }
101 }
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 }
167 }
168
169 /// A file size, in the largest unit that keeps it above 1.
170 fn file_size(bytes: i64) -> String {
171 const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
172 let mut size = bytes as f64;
173 let mut unit = 0;
174 while size >= 1024.0 && unit < UNITS.len() - 1 {
175 size /= 1024.0;
176 unit += 1;
177 }
178 if unit == 0 {
179 format!("{bytes} {}", UNITS[0])
180 } else {
181 format!("{size:.1} {}", UNITS[unit])
182 }
183 }
184
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>,
192 }
193
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.
284 ///
285 /// `list_milestones` computes the same three numbers in the command layer. Not
286 /// shared, because sharing it would mean lifting `MilestoneResponse` out of the
287 /// command module into something both can see, and the arithmetic is one line.
288 /// The comment is the guard: if the rule for what counts as done ever stops
289 /// being "status is Completed", both move together.
290 fn milestone_progress(tasks: &[Task], milestone: &Milestone) -> (usize, usize) {
291 let mine: Vec<&Task> = tasks
292 .iter()
293 .filter(|task| task.milestone_id == Some(milestone.id))
294 .collect();
295 let done = mine
296 .iter()
297 .filter(|task| task.status == TaskStatus::Completed)
298 .count();
299 (done, mine.len())
300 }
301
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>,
317 }
318
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(
333 project: ProjectId,
334 all: &[Milestone],
335 tasks: &[Task],
336 show_completed: bool,
337 ) -> Milestones {
338 let (open, done): (Vec<Milestone>, Vec<Milestone>) = all
339 .iter()
340 .cloned()
341 .partition(|milestone| milestone.status != MilestoneStatus::Completed);
342
343 let of = open.len();
344 Milestones {
345 project,
346 open: open
347 .into_iter()
348 .enumerate()
349 .map(|(at, milestone)| {
350 let (done, total) = milestone_progress(tasks, &milestone);
351 Standing {
352 progress: Meter::new(counted(done), counted(total)).label("tasks"),
353 first: at == 0,
354 last: at + 1 == of,
355 target: milestone
356 .target_date
357 .map(|date| date.format("%Y-%m-%d").to_string()),
358 milestone,
359 }
360 })
361 .collect(),
362 done,
363 show_completed,
364 bare: all.is_empty(),
365 }
366 }
367
368 /// What the completed-milestones disclosure reads.
369 fn completed_label(milestones: &Milestones) -> String {
370 if milestones.show_completed {
371 "Hide completed".to_owned()
372 } else {
373 format!("Show {} completed", milestones.done.len())
374 }
375 }
376
377 /// What pressing that disclosure leaves it set to.
378 fn completed_next(milestones: &Milestones) -> bool {
379 !milestones.show_completed
380 }
381
382 declare! {
383 /// One open milestone, with the controls that act on it.
384 ///
385 /// Reordering is two acts rather than a drag. The act that would go off the
386 /// end is `Act::disabled` rather than hidden: a control that vanishes at the
387 /// edge of a list is a control the user has to discover twice.
388 shape milestone_row(milestones: &Milestones, standing: &Standing) -> Row;
389
390 row &standing.milestone.name {
391 meter standing.progress.clone();
392
393 for target in standing.target.iter() {
394 token Tag::badge(target);
395 }
396
397 act "Edit"
398 to get "/projects/{milestones.project}/milestones/{standing.milestone.id}/edit";
399
400 act "Move up"
401 to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/move"
402 with "by" "-1" {
403 disabled when standing.first;
404 }
405
406 act "Move down"
407 to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/move"
408 with "by" "1" {
409 disabled when standing.last;
410 }
411
412 act "Delete"
413 to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/delete" {
414 tone Danger;
415 }
416 }
417 }
418
419 declare! {
420 /// The milestones section.
421 ///
422 /// Add and edit are addresses rather than modals, on the shape the task
423 /// overview established: an edit form is a screen of its own rather than a
424 /// control on the screen it edits. That is why they were absent when this
425 /// screen first landed, and it is the same reason they are here now -- the
426 /// forms exist as `/projects/{id}/milestones/new` and
427 /// `/projects/{id}/milestones/{milestone}/edit`, so the controls are links.
428 ///
429 /// The completed disclosure is an address, so an expanded dashboard survives
430 /// a reload and can be linked to. `showCompletedMilestones` in the JS is
431 /// module state that a re-render throws away.
432 shape milestones(milestones: &Milestones) -> Vec<Node>;
433
434 section "Milestones";
435
436 empty "No milestones yet" when milestones.bare;
437
438 act "New milestone" to get "/projects/{milestones.project}/milestones/new";
439
440 list {
441 for standing in milestones.open.iter() {
442 include milestone_row(milestones, standing);
443 }
444 } unless milestones.bare;
445
446 act completed_label(milestones)
447 to doing filtered_by(
448 Action::get("/projects/{milestones.project}/dashboard"),
449 "completed",
450 completed_next(milestones)
451 )
452 when shows_completed(milestones);
453
454 list {
455 for milestone in milestones.done.iter() {
456 row &milestone.name {
457 token Tag::badge("Complete").tone(Tone::Success);
458 }
459 }
460 } when milestones.show_completed and shows_completed(milestones);
461 }
462
463 /// Whether there are completed milestones to disclose.
464 fn shows_completed(milestones: &Milestones) -> bool {
465 !milestones.bare && !milestones.done.is_empty()
466 }
467
468 /// Everything the dashboard draws, read once.
469 struct Dashboard {
470 project: Project,
471 tasks: Vec<Task>,
472 events: Vec<Event>,
473 emails: Vec<Email>,
474 attached: Attached,
475 milestones: Milestones,
476 }
477
478 /// Read the dashboard.
479 ///
480 /// `attach_error` is what a refused attach carries back into the column it was
481 /// refused in. Every other caller passes `None`.
482 fn read(
483 state: &AppState,
484 id: ProjectId,
485 show_completed: bool,
486 attach_error: Option<&str>,
487 ) -> Result<Dashboard, RouteError> {
488 let project: Project = state
489 .projects
490 .get_by_id(id, DESKTOP_USER_ID)
491 .map_err(|error| RouteError::internal(error.to_string()))?
492 .ok_or_else(|| RouteError::not_found("no such project"))?;
493
494 let internal = |error: goingson_core::CoreError| RouteError::internal(error.to_string());
495 let tasks = state
496 .tasks
497 .list_by_project(DESKTOP_USER_ID, id)
498 .map_err(internal)?;
499 let all_milestones = state
500 .milestones
501 .list_by_project(id, DESKTOP_USER_ID)
502 .map_err(internal)?;
503
504 Ok(Dashboard {
505 milestones: milestones_of(id, &all_milestones, &tasks, show_completed),
506 events: state
507 .events
508 .list_by_project(DESKTOP_USER_ID, id)
509 .map_err(internal)?,
510 emails: state
511 .emails
512 .list_by_project(DESKTOP_USER_ID, id)
513 .map_err(internal)?,
514 attached: attached(state, id, attach_error)?,
515 project,
516 tasks,
517 })
518 }
519
520 declare! {
521 /// One column, as a region of its own.
522 ///
523 /// # The arrangement finding
524 ///
525 /// **Four peer columns are neither of the two arrangements.**
526 /// `Arrangement` is `ListDetail` or `SidebarContent`, taken from what the
527 /// two webview apps do, and this screen does a third thing: four equal panes
528 /// side by side under a band, none of which chooses what another shows.
529 ///
530 /// The regions themselves are fine -- a `Pane` each, and `Slot` takes as
531 /// many as it is given -- so the screen renders. What is wrong is that it
532 /// has to claim `ListDetail` while being nothing of the sort, and a renderer
533 /// that laid out list-detail faithfully would put the emails pane where the
534 /// detail goes.
535 ///
536 /// Not filed as its own task. `Arrangement`'s docs say the two members came
537 /// from measuring the two apps and that discovering the layer missing after
538 /// the renderers exist is a redesign; this is the first counter-example and
539 /// one counter-example is not a member. It is noted on the
540 /// `Region::Handover` reasoning instead: a dashboard is a candidate for the
541 /// app owning its own arrangement, the way it owns the heatmap.
542 shape column(id: &str, title: &str, body: Node) -> Slot;
543
544 region id as Pane {
545 section title;
546 include body;
547 }
548 }
549
550 declare! {
551 /// The whole dashboard.
552 shape screen(dashboard: &Dashboard) -> Screen;
553
554 screen list_detail "Project" false {
555 at_place crate::quasi::shell::PROJECTS;
556
557 region "dashboard-band" as Band {
558 page &dashboard.project.name;
559
560 badge type_label(&dashboard.project.project_type);
561 badge dashboard.project.status.as_str() {
562 tone status_tone(&dashboard.project.status);
563 }
564
565 act "Back to projects" to get "/projects/{dashboard.project.id}";
566 }
567
568 region "dashboard-milestones" as Pane {
569 extend milestones(&dashboard.milestones);
570 }
571
572 include column("dashboard-tasks", "Tasks", tasks_column(&dashboard.tasks));
573 include column("dashboard-events", "Events", events_column(&dashboard.events));
574 include column("dashboard-emails", "Emails", emails_column(&dashboard.emails));
575 include attachments_column(&dashboard.attached);
576 }
577 }
578
579 /// Whether a param is on.
580 fn flag(request: &quasi_router::Request, name: &str) -> bool {
581 matches!(request.carried.get(name), Some("1" | "true"))
582 }
583
584 /// The dashboard.
585 fn dashboard(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
586 let id = project_id(&request)?;
587 Ok(screen(&read(state, id, flag(&request, "completed"), None)?).into())
588 }
589
590 /// Answer a write with the dashboard it happened on, re-read.
591 fn wrote(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Response, RouteError> {
592 Ok(screen(&read(state, id, show_completed, None)?).into())
593 }
594
595 /// The attachments column alone, re-read.
596 ///
597 /// What attaching answers with, refused or not. A milestone write reflows the
598 /// document because it lands in two places at once; attaching lands in one, so
599 /// it takes decision 7's narrow swap and leaves the rest of the screen — and a
600 /// half-expanded milestones section — where it was.
601 fn attachments_pane(
602 state: &AppState,
603 id: ProjectId,
604 error: Option<&str>,
605 ) -> Result<Response, RouteError> {
606 Ok(Response::fragment(
607 "dashboard-attachments",
608 Node::Region(attachments_column(&attached(state, id, error)?)),
609 ))
610 }
611
612 /// Attach the picked file to this project.
613 ///
614 /// The path arrives under `file`, which is the name the `FieldKind::File` field
615 /// submits under. See [`attachments_column`] for why it is a path and what that
616 /// leaves open.
617 fn attach(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
618 let id = project_id(&request)?;
619 let picked = request
620 .payload
621 .get("file")
622 .unwrap_or_default()
623 .trim()
624 .to_owned();
625 if picked.is_empty() {
626 return attachments_pane(state, id, Some("Choose a file to attach."));
627 }
628
629 match crate::commands::attachment::attach_path(state, None, Some(id), None, &picked) {
630 Ok(attachment) => Ok(attachments_pane(state, id, None)?
631 .toast(Tone::Success, format!("Attached {}.", attachment.filename))),
632 // A failure is ours and is not something a form can say anything useful
633 // about. Everything else is the user's to fix by picking another file,
634 // so it goes back on the field — including the project having gone,
635 // which is a stale screen rather than a 404 worth navigating to.
636 Err(crate::commands::attachment::AttachFailure::Failed(message)) => {
637 Err(RouteError::internal(message))
638 }
639 Err(failure) => attachments_pane(state, id, Some(&failure.message())),
640 }
641 }
642
643 /// The attachment a route was addressed at.
644 fn attachment_id(
645 request: &quasi_router::Request,
646 ) -> Result<goingson_core::AttachmentId, RouteError> {
647 let raw = request
648 .captures
649 .get("attachment")
650 .ok_or_else(|| RouteError::not_found("no attachment id"))?;
651 Ok(goingson_core::AttachmentId::from(
652 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an attachment id"))?,
653 ))
654 }
655
656 /// Hand one attachment to whatever the host opens files with.
657 ///
658 /// A `GET`, because nothing about the project changes: the spool copy is how a
659 /// content-addressed blob is read under the name it was attached with, not a
660 /// write the user made.
661 fn open(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
662 let id = attachment_id(&request)?;
663 let attachment = state
664 .attachments
665 .get_by_id(id, DESKTOP_USER_ID)
666 .map_err(|error| RouteError::internal(error.to_string()))?
667 .ok_or_else(|| RouteError::not_found("no such attachment"))?;
668
669 let spooled = crate::commands::attachment::spool(
670 &state.data_dir,
671 &attachment.blob_hash,
672 &attachment.filename,
673 )
674 .map_err(|failure| match failure {
675 crate::commands::attachment::AttachFailure::Failed(message) => {
676 RouteError::internal(message)
677 }
678 // Not there yet rather than not there at all: an unsynced blob is a
679 // thing the user can wait for, and the message says which it is.
680 other => RouteError::not_found(other.message()),
681 })?;
682
683 Ok(Response::goto(Action::external(file_url(&spooled))))
684 }
685
686 /// A `file://` address for a path on this machine.
687 ///
688 /// Percent-encoded by hand rather than by a crate: one call site, and the whole
689 /// rule is that everything outside the unreserved set goes out as `%XX` with
690 /// the separator kept. A filename with a space or a `#` in it is the common
691 /// case this exists for, and both would otherwise truncate the address.
692 fn file_url(path: &std::path::Path) -> String {
693 use std::fmt::Write as _;
694
695 let mut url = String::from("file://");
696 for byte in path.to_string_lossy().bytes() {
697 match byte {
698 b'/' | b'-' | b'.' | b'_' | b'~' => url.push(byte as char),
699 _ if byte.is_ascii_alphanumeric() => url.push(byte as char),
700 // Infallible into a `String`, and the one thing a `?` here could
701 // report is that formatting failed, which it cannot.
702 _ => {
703 let _ = write!(url, "%{byte:02X}");
704 }
705 }
706 }
707 url
708 }
709
710 /// The milestone a route was addressed at.
711 fn milestone_id(request: &quasi_router::Request) -> Result<goingson_core::MilestoneId, RouteError> {
712 let raw = request
713 .captures
714 .get("milestone")
715 .ok_or_else(|| RouteError::not_found("no milestone id"))?;
716 Ok(goingson_core::MilestoneId::from(
717 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a milestone id"))?,
718 ))
719 }
720
721 /// The two states a milestone can be put into by hand.
722 ///
723 /// Spelled as the display strings rather than the db values, because
724 /// `MilestoneStatus` parses the display form (`#[strum(serialize = "Open")]`)
725 /// and `as_str` produces it, so the field's value and its options agree without
726 /// a mapping in between.
727 const MILESTONE_STATUSES: &[&str] = &["Open", "Completed"];
728
729 /// What the milestone form is filling in, and what it is answering.
730 struct Asking<'a> {
731 project: ProjectId,
732 existing: Option<&'a Milestone>,
733 errors: &'a [(&'a str, String)],
734 submitted: Option<&'a quasi_router::Params>,
735 }
736
737 /// Whether the form is editing rather than adding.
738 fn is_edit(asking: &Asking) -> bool {
739 asking.existing.is_some()
740 }
741
742 /// What one question holds.
743 ///
744 /// A refused submission wins over the stored value, and the stored value over
745 /// nothing. Reading the submission first is what stops a validation error from
746 /// handing back the row as it was and losing the edit.
747 fn value_of(asking: &Asking, name: &str) -> String {
748 asking
749 .submitted
750 .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
751 .unwrap_or_else(|| stored(asking, name))
752 }
753
754 /// What the milestone itself says.
755 fn stored(asking: &Asking, name: &str) -> String {
756 let Some(milestone) = asking.existing else {
757 return String::new();
758 };
759 match name {
760 "name" => milestone.name.clone(),
761 "description" => milestone.description.clone(),
762 "target_date" => milestone
763 .target_date
764 .map(|date| date.format("%Y-%m-%d").to_string())
765 .unwrap_or_default(),
766 "status" => milestone.status.as_str().to_owned(),
767 _ => String::new(),
768 }
769 }
770
771 /// Whether a named question was refused.
772 fn has_error(asking: &Asking, name: &str) -> bool {
773 asking.errors.iter().any(|(field, _)| *field == name)
774 }
775
776 /// Why it was refused, or nothing.
777 fn error_for(asking: &Asking, name: &str) -> String {
778 asking
779 .errors
780 .iter()
781 .find(|(field, _)| *field == name)
782 .map(|(_, message)| message.clone())
783 .unwrap_or_default()
784 }
785
786 /// What the form is called.
787 fn form_title(asking: &Asking) -> String {
788 asking.existing.map_or_else(
789 || "New milestone".to_owned(),
790 |milestone| format!("Edit {}", milestone.name),
791 )
792 }
793
794 /// Where it writes.
795 fn form_path(asking: &Asking) -> String {
796 match asking.existing {
797 Some(milestone) => format!("/projects/{}/milestones/{}", asking.project, milestone.id),
798 None => format!("/projects/{}/milestones", asking.project),
799 }
800 }
801
802 /// What its button reads.
803 fn form_submit(asking: &Asking) -> &'static str {
804 if is_edit(asking) {
805 "Save milestone"
806 } else {
807 "Create milestone"
808 }
809 }
810
811 declare! {
812 /// The add or edit form as a screen of its own.
813 ///
814 /// Both are addresses rather than overlays, on the shape task edit
815 /// established (goingson@2384df1) and for the reason recorded there: a modal
816 /// is a second arrangement drawn over the first, and a screen that offers a
817 /// control which opens a form over itself has to describe two arrangements
818 /// at once. Cancel is the dashboard's own address, and the dashboard is
819 /// rebuilt from the database rather than restored from memory.
820 ///
821 /// Status is asked only on an edit: a milestone being created is Open, and
822 /// a question with one useful answer is not a question.
823 shape milestone_form(asking: &Asking) -> Screen;
824
825 screen list_detail "Milestone" false {
826 at_place crate::quasi::shell::PROJECTS;
827
828 region "milestone-band" as Band {
829 page form_title(asking);
830 act "Cancel" to get "/projects/{asking.project}/dashboard";
831 }
832
833 region "milestone-form" as Pane {
834 form post form_path(asking) {
835 submit form_submit(asking);
836
837 field Text "name" "Name" {
838 required;
839 placeholder "What does reaching it mean?";
840 value value_of(asking, "name");
841 error error_for(asking, "name") when has_error(asking, "name");
842 }
843
844 field Textarea "description" "Description" {
845 placeholder "Anything the name does not cover (optional)";
846 value value_of(asking, "description");
847 error error_for(asking, "description")
848 when has_error(asking, "description");
849 }
850
851 field Text "target_date" "Target Date (optional)" {
852 placeholder "next friday, 2026-03-01...";
853 value value_of(asking, "target_date");
854 error error_for(asking, "target_date")
855 when has_error(asking, "target_date");
856 }
857
858 field Select "status" "Status" when is_edit(asking) {
859 for offered in MILESTONE_STATUSES.iter().copied() {
860 option Choice::new(offered, offered);
861 }
862 value value_of(asking, "status");
863 error error_for(asking, "status") when has_error(asking, "status");
864 }
865 }
866 }
867 }
868 }
869
870 /// Load one milestone, refusing one that belongs to another project.
871 ///
872 /// `get_by_id` scopes by user and not by project, so the project in the address
873 /// is checked here. Without it `/projects/{a}/milestones/{b-of-another}/edit`
874 /// would render a form that saves to a milestone the address does not name.
875 fn load_milestone(
876 state: &AppState,
877 project: ProjectId,
878 id: goingson_core::MilestoneId,
879 ) -> Result<Milestone, RouteError> {
880 let milestone = state
881 .milestones
882 .get_by_id(id, DESKTOP_USER_ID)
883 .map_err(|error| RouteError::internal(error.to_string()))?
884 .ok_or_else(|| RouteError::not_found("no such milestone"))?;
885 if milestone.project_id != project {
886 return Err(RouteError::not_found("no such milestone"));
887 }
888 Ok(milestone)
889 }
890
891 /// What a submitted milestone must satisfy.
892 ///
893 /// A described `.required()` is a claim to the renderer rather than a check:
894 /// the form can be submitted past it, so the refusal has to exist here too.
895 fn validate_milestone(name: &str) -> Vec<(&'static str, String)> {
896 let mut errors = Vec::new();
897 if name.is_empty() {
898 errors.push(("name", "A milestone needs a name.".to_owned()));
899 }
900 errors
901 }
902
903 /// The target date, parsed from what the field holds.
904 ///
905 /// A described form has no transform step, so "next friday" is parsed here,
906 /// against the same core function the task form uses. A date is a day rather
907 /// than an instant, so the time half of the parse is dropped.
908 fn milestone_target(
909 raw: &str,
910 errors: &mut Vec<(&'static str, String)>,
911 ) -> Option<chrono::NaiveDate> {
912 if raw.is_empty() {
913 return None;
914 }
915 match goingson_core::parse_natural_date(raw, Local::now().naive_local()) {
916 Some(when) => Some(when.date()),
917 None => {
918 errors.push((
919 "target_date",
920 "Date not recognized. Try \"next friday\" or \"2026-03-01\".".to_owned(),
921 ));
922 None
923 }
924 }
925 }
926
927 /// The add form.
928 fn new_milestone(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
929 let project = project_id(&request)?;
930 // Loaded rather than trusted: the form posts to this project, so an address
931 // naming one that does not exist should say so now rather than on submit.
932 state
933 .projects
934 .get_by_id(project, DESKTOP_USER_ID)
935 .map_err(|error| RouteError::internal(error.to_string()))?
936 .ok_or_else(|| RouteError::not_found("no such project"))?;
937 Ok(milestone_form(&Asking {
938 project,
939 existing: None,
940 errors: &[],
941 submitted: None,
942 })
943 .into())
944 }
945
946 /// Create it, or answer with the form saying why not.
947 ///
948 /// `position` is the count of what is already there, which is what
949 /// `list_by_project` orders by: a new milestone lands last, and `move` is how
950 /// it gets anywhere else.
951 fn create_milestone(
952 state: &AppState,
953 request: quasi_router::Request,
954 ) -> Result<Response, RouteError> {
955 let project = project_id(&request)?;
956 let field = |name: &str| {
957 request
958 .payload
959 .get(name)
960 .unwrap_or_default()
961 .trim()
962 .to_owned()
963 };
964 let name = field("name");
965 let mut errors = validate_milestone(&name);
966 let target_date = milestone_target(&field("target_date"), &mut errors);
967
968 if !errors.is_empty() {
969 return Ok(milestone_form(&Asking {
970 project,
971 existing: None,
972 errors: &errors,
973 submitted: Some(&request.payload),
974 })
975 .into());
976 }
977
978 let existing = state
979 .milestones
980 .list_by_project(project, DESKTOP_USER_ID)
981 .map_err(|error| RouteError::internal(error.to_string()))?;
982
983 state
984 .milestones
985 .create(
986 DESKTOP_USER_ID,
987 goingson_core::NewMilestone {
988 project_id: project,
989 name,
990 description: field("description"),
991 position: i32::try_from(existing.len()).unwrap_or(i32::MAX),
992 target_date,
993 },
994 )
995 .map_err(|error| RouteError::internal(error.to_string()))?;
996
997 wrote(state, project, flag(&request, "completed"))
998 }
999
1000 /// The edit form.
1001 fn edit_milestone(
1002 state: &AppState,
1003 request: quasi_router::Request,
1004 ) -> Result<Response, RouteError> {
1005 let project = project_id(&request)?;
1006 let milestone = load_milestone(state, project, milestone_id(&request)?)?;
1007 Ok(milestone_form(&Asking {
1008 project,
1009 existing: Some(&milestone),
1010 errors: &[],
1011 submitted: None,
1012 })
1013 .into())
1014 }
1015
1016 /// Save the edited milestone, or answer with the form saying why not.
1017 fn update_milestone(
1018 state: &AppState,
1019 request: quasi_router::Request,
1020 ) -> Result<Response, RouteError> {
1021 let project = project_id(&request)?;
1022 let milestone = load_milestone(state, project, milestone_id(&request)?)?;
1023
1024 let field = |name: &str| {
1025 request
1026 .payload
1027 .get(name)
1028 .unwrap_or_default()
1029 .trim()
1030 .to_owned()
1031 };
1032 let name = field("name");
1033 let mut errors = validate_milestone(&name);
1034 let target_date = milestone_target(&field("target_date"), &mut errors);
1035 let status =
1036 super::super::parse_choice::<MilestoneStatus>(&request.payload, "status", &mut errors);
1037
1038 let (Some(status), true) = (status, errors.is_empty()) else {
1039 return Ok(milestone_form(&Asking {
1040 project,
1041 existing: Some(&milestone),
1042 errors: &errors,
1043 submitted: Some(&request.payload),
1044 })
1045 .into());
1046 };
1047
1048 state
1049 .milestones
1050 .update(
1051 milestone.id,
1052 DESKTOP_USER_ID,
1053 &name,
1054 &field("description"),
1055 target_date,
1056 &status,
1057 )
1058 .map_err(|error| RouteError::internal(error.to_string()))?
1059 .ok_or_else(|| RouteError::not_found("no such milestone"))?;
1060
1061 wrote(state, project, flag(&request, "completed"))
1062 }
1063
1064 /// Move a milestone one place up or down among the open ones.
1065 ///
1066 /// `reorder` takes the whole order rather than a swap, so the handler reads the
1067 /// current order, moves one, and writes it back. The order it writes is every
1068 /// milestone, completed ones included: they carry positions too, and sending
1069 /// only the open ones would silently renumber the rest.
1070 fn move_milestone(
1071 state: &AppState,
1072 request: quasi_router::Request,
1073 ) -> Result<Response, RouteError> {
1074 let project = project_id(&request)?;
1075 let target = milestone_id(&request)?;
1076 let by: i32 = request
1077 .payload
1078 .get("by")
1079 .and_then(|raw| raw.parse().ok())
1080 .filter(|by| *by == -1 || *by == 1)
1081 .ok_or_else(|| RouteError::not_found("move by -1 or 1"))?;
1082
1083 let mut order: Vec<goingson_core::MilestoneId> = state
1084 .milestones
1085 .list_by_project(project, DESKTOP_USER_ID)
1086 .map_err(|error| RouteError::internal(error.to_string()))?
1087 .iter()
1088 .map(|milestone| milestone.id)
1089 .collect();
1090
1091 let at = order
1092 .iter()
1093 .position(|id| *id == target)
1094 .ok_or_else(|| RouteError::not_found("no such milestone"))?;
1095 let to = at as i32 + by;
1096 // Off either end is a no-op rather than an error: the control that sent it
1097 // is disabled, so arriving here means a stale screen, and the right answer
1098 // to a stale screen is a fresh one.
1099 if to >= 0 && (to as usize) < order.len() {
1100 order.swap(at, to as usize);
1101 state
1102 .milestones
1103 .reorder(project, DESKTOP_USER_ID, &order)
1104 .map_err(|error| RouteError::internal(error.to_string()))?;
1105 }
1106
1107 wrote(state, project, flag(&request, "completed"))
1108 }
1109
1110 /// Delete a milestone.
1111 fn delete_milestone(
1112 state: &AppState,
1113 request: quasi_router::Request,
1114 ) -> Result<Response, RouteError> {
1115 let project = project_id(&request)?;
1116 let deleted = state
1117 .milestones
1118 .delete(milestone_id(&request)?, DESKTOP_USER_ID)
1119 .map_err(|error| RouteError::internal(error.to_string()))?;
1120 if !deleted {
1121 return Err(RouteError::not_found("no such milestone"));
1122 }
1123 wrote(state, project, flag(&request, "completed"))
1124 }
1125
1126 /// The dashboard's routes.
1127 #[must_use]
1128 pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
1129 router
1130 .get("/projects/{id}/dashboard", dashboard)
1131 // Above `/milestones/{milestone}/...`, because `new` and `edit` are
1132 // literal segments where the others capture: a router that matched the
1133 // capture first would read `new` as a milestone id.
1134 .get("/projects/{id}/milestones/new", new_milestone)
1135 .post("/projects/{id}/milestones", create_milestone)
1136 .get("/projects/{id}/milestones/{milestone}/edit", edit_milestone)
1137 .post("/projects/{id}/milestones/{milestone}", update_milestone)
1138 .post("/projects/{id}/milestones/{milestone}/move", move_milestone)
1139 .post(
1140 "/projects/{id}/milestones/{milestone}/delete",
1141 delete_milestone,
1142 )
1143 .post("/projects/{id}/attachments", attach)
1144 .get("/projects/{id}/attachments/{attachment}/open", open)
1145 }
1146