Skip to main content

max / goingson

Describe the project dashboard through quasi Fourth screen. What a project has linked to it, in four columns, plus its milestones with their reorder and delete controls. Behind the same off-by-default feature. 16 tests. Not the next one by weight, deliberately. The porting order on 7c8e6be2 is by esc() count and its head was settings-sync.js at 17, which is not a screen: an OAuth handshake against an external browser with a localhost callback poll, an encryption-password setup, and Stripe polling. Weight measures what a port retires, which is the payoff; it says nothing about whether the thing is describable, which is the precondition. Order by weight among describable screens. showCompletedMilestones was module state a re-render threw away and is an address now, so an expanded dashboard survives a reload. Third time decision 2 has paid out and the first where the state was a disclosure rather than a filter. One new finding, filed on quasi rather than makeover-layout because it is about what an action can reach: an action that opens a native dialog is not a route (844b5ae0). The attach and open controls are host capabilities asked for by name, neither a route this app answers nor an external address. Left out rather than pointed at routes that cannot exist. It may not be a gap at all -- Region::Bespoke may be the whole answer -- and the task says so. Also noted rather than filed: four peer columns are neither Arrangement member, so the screen claims ListDetail while being nothing of the sort. One counter-example is not a member; recorded on the bespoke reasoning. Reorder controls are disabled at the ends rather than hidden. A control that vanishes at the edge of a list is one the user has to discover twice.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 03:31 UTC
Signed with PGP, not checked
Commit: f72d8bcaa6abe4d4bc68701419beb1c297796396
Parent: a76f6d8
3 files changed, +896 insertions, -13 deletions
@@ -50,6 +50,8 @@
50 50
51 51 use crate::state::{AppState, DESKTOP_USER_ID};
52 52
53 + mod dashboard;
54 +
53 55 #[cfg(test)]
54 56 mod tests;
55 57
@@ -66,7 +68,7 @@
66 68 }
67 69
68 70 /// The display name of a project type.
69 - fn type_label(project_type: &ProjectType) -> &'static str {
71 + pub(super) fn type_label(project_type: &ProjectType) -> &'static str {
70 72 match project_type {
71 73 ProjectType::SideProject => "Side Project",
72 74 ProjectType::Job => "Job",
@@ -112,7 +114,7 @@
112 114 /// screen goes.
113 115 ///
114 116 /// Archived returning neutral is deliberate there and here: it is not news.
115 - const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone {
117 + pub(super) const fn status_tone(status: &ProjectStatus) -> makeover_layout::Tone {
116 118 match status {
117 119 ProjectStatus::Active => makeover_layout::Tone::Info,
118 120 ProjectStatus::OnHold => makeover_layout::Tone::Warning,
@@ -225,19 +227,23 @@
225 227 matches!(params.get(name), Some("1" | "true"))
226 228 }
227 229
230 + /// The same action, carrying one flag if it is on.
231 + ///
232 + /// Absent means off, which is what a URL without it means, so an off flag is
233 + /// never written. That is what keeps two addresses for the same view from
234 + /// existing.
235 + pub(super) fn filtered_by(action: Action, name: &str, on: bool) -> Action {
236 + if on { action.with(name, "1") } else { action }
237 + }
238 +
228 239 /// The same action, carrying the filters the screen was under.
229 240 ///
230 241 /// Every address on this screen goes through here, including the two writes.
231 242 /// A filtered view whose controls drop the filters is a view you fall out of by
232 243 /// using it, and the filters are the only state this screen has.
233 - fn filtered(mut action: Action, shared_only: bool, show_retired: bool) -> Action {
234 - if shared_only {
235 - action = action.with("shared", "1");
236 - }
237 - if show_retired {
238 - action = action.with("retired", "1");
239 - }
240 - action
244 + fn filtered(action: Action, shared_only: bool, show_retired: bool) -> Action {
245 + let action = filtered_by(action, "shared", shared_only);
246 + filtered_by(action, "retired", show_retired)
241 247 }
242 248
243 249 /// The address of the grid under a given pair of filters.
@@ -304,7 +310,9 @@
304 310 ///
305 311 /// `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the uuid
306 312 /// crate's. Not worth adding one upstream for two call sites.
307 - fn project_id(params: &quasi_router::Params) -> Result<goingson_core::ProjectId, RouteError> {
313 + pub(super) fn project_id(
314 + params: &quasi_router::Params,
315 + ) -> Result<goingson_core::ProjectId, RouteError> {
308 316 let raw = params
309 317 .get("id")
310 318 .ok_or_else(|| RouteError::not_found("no project id"))?;
@@ -578,11 +586,12 @@
578 586 // `/projects/new` and `/projects/:id` collide, and the router settles it by
579 587 // specificity rather than by the order they are written in, so `new` is
580 588 // tried first wherever it sits here.
581 - router
589 + let router = router
582 590 .get("/projects", index)
583 591 .get("/projects/list", list)
584 592 .get("/projects/new", new)
585 593 .get("/projects/:id", detail)
586 594 .post("/projects", create)
587 - .post("/projects/:id/delete", remove)
595 + .post("/projects/:id/delete", remove);
596 + dashboard::routes(router)
588 597 }
@@ -1,0 +1,468 @@
1 + //! The project dashboard, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Fourth screen ported. `projects-render.js`, 241 lines: what a project has
6 + //! linked to it, in four columns, plus its milestones. The shipped screen is
7 + //! that file unchanged.
8 + //!
9 + //! # Chosen over the next one by weight, deliberately
10 + //!
11 + //! The porting order on goingson task `7c8e6be2` is by `esc()` count, and the
12 + //! head of that list was `settings-sync.js` at 17. It is not a screen. It is an
13 + //! OAuth handshake against an external browser with a localhost callback poll,
14 + //! an encryption-password setup, and Stripe subscription polling — workflows
15 + //! wearing a settings panel, and the description layer has nothing to say about
16 + //! any of it.
17 + //!
18 + //! Weight measures how much escaping a port retires, which is the payoff. It
19 + //! says nothing about whether the thing is describable, which is the
20 + //! precondition. Order by weight *among describable screens*; a count that picks
21 + //! a workflow is a count being read as a plan.
22 + //!
23 + //! # The shape
24 + //!
25 + //! - `GET /projects/{id}/dashboard` — the whole thing.
26 + //! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`.
27 + //! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one.
28 + //!
29 + //! `showCompletedMilestones` is module state in the JS, re-rendered from a
30 + //! cached list. Here it is `?completed=1`, so the expanded dashboard is
31 + //! reachable by address. Third time decision 2 has paid out on a real screen and
32 + //! the first where the state was a disclosure rather than a filter.
33 + //!
34 + //! Two controls are described and two are not, and the line between them is the
35 + //! finding this port turned up. See [`attachments_column`].
36 +
37 + #![allow(clippy::needless_pass_by_value)]
38 +
39 + use chrono::Local;
40 + use goingson_core::{
41 + Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus,
42 + };
43 + use quasi_router::screen::{Act, Row, Tag};
44 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
45 +
46 + use super::{filtered_by, project_id, status_tone, type_label};
47 + use crate::state::{AppState, DESKTOP_USER_ID};
48 +
49 + #[cfg(test)]
50 + mod tests;
51 +
52 + /// One linked task.
53 + ///
54 + /// The subtask bar the JS draws under the title is text in the meta slot, which
55 + /// is the proportion finding meeting its third and fourth call sites — a project
56 + /// dashboard draws one per task and one per milestone. makeover-layout
57 + /// `d0b58239`.
58 + fn task_row(task: &Task) -> Row {
59 + let mut row = Row::new(&task.title).token(Tag::badge(task.priority.as_str()));
60 + if task.subtask_count() > 0 {
61 + row = row.meta(format!(
62 + "{}/{} subtasks",
63 + task.subtasks_completed(),
64 + task.subtask_count()
65 + ));
66 + }
67 + if task.due.is_some() {
68 + row = row.token(Tag::badge(task.due_formatted()));
69 + }
70 + row.activate(Action::get(format!("/tasks/{}", task.id)))
71 + }
72 +
73 + /// The tasks column.
74 + ///
75 + /// Three states rather than two, which is the JS's own distinction and worth
76 + /// keeping: nothing linked yet is a different thing from everything being done.
77 + fn tasks_column(tasks: &[Task]) -> Node {
78 + if tasks.is_empty() {
79 + return Node::text("No tasks linked yet.");
80 + }
81 + if tasks
82 + .iter()
83 + .all(|task| task.status == TaskStatus::Completed)
84 + {
85 + return Node::text("All tasks complete.");
86 + }
87 + Node::list(tasks.iter().map(task_row))
88 + }
89 +
90 + /// The events column.
91 + fn events_column(events: &[Event]) -> Node {
92 + if events.is_empty() {
93 + return Node::text("No events linked yet.");
94 + }
95 + Node::list(events.iter().map(|event| {
96 + let at = event.start_time.with_timezone(&Local);
97 + Row::new(&event.title).meta(at.format("%b %-d, %-I:%M %p").to_string())
98 + }))
99 + }
100 +
101 + /// The emails column.
102 + fn emails_column(emails: &[Email]) -> Node {
103 + if emails.is_empty() {
104 + return Node::text("No emails linked yet.");
105 + }
106 + Node::list(emails.iter().map(|email| {
107 + let mut row = Row::new(&email.subject)
108 + .secondary(&email.from)
109 + .meta(email.received_formatted());
110 + if !email.is_read {
111 + row = row.token(Tag::badge("Unread").tone(makeover_layout::Tone::Info));
112 + }
113 + row
114 + }))
115 + }
116 +
117 + /// A file size the way the JS writes one.
118 + fn file_size(bytes: i64) -> String {
119 + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
120 + let mut size = bytes as f64;
121 + let mut unit = 0;
122 + while size >= 1024.0 && unit < UNITS.len() - 1 {
123 + size /= 1024.0;
124 + unit += 1;
125 + }
126 + if unit == 0 {
127 + format!("{bytes} {}", UNITS[0])
128 + } else {
129 + format!("{size:.1} {}", UNITS[unit])
130 + }
131 + }
132 +
133 + /// The attachments column.
134 + ///
135 + /// # The finding this port turned up
136 + ///
137 + /// **An action that opens a native dialog is not a route, and `Destination` has
138 + /// nowhere to put it.** The empty state carries an "Attach File" button, and it
139 + /// calls `attachments.pickAndAttach`, which opens the OS file picker. That is
140 + /// neither of `Destination`'s two members: it is not a route this app answers,
141 + /// and it is not an external address a browser navigates to. It is a capability
142 + /// of the host, asked for by name.
143 + ///
144 + /// The same is true of opening one: `attachments.openPanel` hands the blob to
145 + /// the OS. So this column is read-only in the description, and both controls are
146 + /// left out rather than pointed at routes that cannot exist.
147 + ///
148 + /// This is a different shape from the other findings and may not be a gap at
149 + /// all. `Region::Bespoke` already says "the app owns what goes here" for a
150 + /// region; the honest reading may be that a host capability is the app's
151 + /// business the same way, and a description that named file pickers would be
152 + /// naming one host's abilities in a crate defined by not doing that. What argues
153 + /// the other way is that every host has *some* answer — a TUI has a path prompt,
154 + /// a server has an upload — which is the usual test for whether something
155 + /// belongs in the vocabulary.
156 + ///
157 + /// Filed as quasicoherent `844b5ae0`, on the router rather than the
158 + /// vocabulary, for the same reason the goto finding went there: it is about what
159 + /// an action can reach, not about what is on screen.
160 + fn attachments_column(attachments: &[Attachment]) -> Node {
161 + if attachments.is_empty() {
162 + return Node::text("No attachments yet.");
163 + }
164 + Node::list(
165 + attachments
166 + .iter()
167 + .map(|file| Row::new(&file.filename).meta(file_size(file.file_size))),
168 + )
169 + }
170 +
171 + /// How far along one milestone is, and how that reads.
172 + ///
173 + /// `list_milestones` computes the same three numbers in the command layer. Not
174 + /// shared, because sharing it would mean lifting `MilestoneResponse` out of the
175 + /// command module into something both can see, and the arithmetic is one line.
176 + /// The comment is the guard: if the rule for what counts as done ever stops
177 + /// being "status is Completed", both move together.
178 + fn milestone_progress(tasks: &[Task], milestone: &Milestone) -> (usize, usize) {
179 + let mine: Vec<&Task> = tasks
180 + .iter()
181 + .filter(|task| task.milestone_id == Some(milestone.id))
182 + .collect();
183 + let done = mine
184 + .iter()
185 + .filter(|task| task.status == TaskStatus::Completed)
186 + .count();
187 + (done, mine.len())
188 + }
189 +
190 + /// One open milestone, with the controls that act on it.
191 + ///
192 + /// Reordering is two acts rather than a drag: the JS draws up and down arrows
193 + /// and hides the one that would go off the end. `Act::disabled` says that
194 + /// better than hiding does — a control that vanishes at the edge of a list is a
195 + /// control the user has to discover twice.
196 + fn milestone_row(
197 + project: ProjectId,
198 + milestone: &Milestone,
199 + tasks: &[Task],
200 + at: usize,
201 + of: usize,
202 + ) -> Row {
203 + let (done, total) = milestone_progress(tasks, milestone);
204 + let mut row = Row::new(&milestone.name).meta(format!("{done}/{total}"));
205 +
206 + if let Some(date) = milestone.target_date {
207 + row = row.token(Tag::badge(date.format("%Y-%m-%d").to_string()));
208 + }
209 +
210 + let move_by = |by: i8| {
211 + Action::post(format!(
212 + "/projects/{project}/milestones/{}/move",
213 + milestone.id
214 + ))
215 + .with("by", by.to_string())
216 + };
217 + let up = Act::new("Move up", move_by(-1));
218 + let down = Act::new("Move down", move_by(1));
219 +
220 + row.act(if at == 0 { up.disabled() } else { up })
221 + .act(if at + 1 == of { down.disabled() } else { down })
222 + .act(
223 + Act::new(
224 + "Delete",
225 + Action::post(format!(
226 + "/projects/{project}/milestones/{}/delete",
227 + milestone.id
228 + )),
229 + )
230 + .tone(makeover_layout::Tone::Danger),
231 + )
232 + }
233 +
234 + /// The milestones section.
235 + ///
236 + /// Edit is absent for the reason it is absent on the task overview: it opens a
237 + /// modal form, and an edit form is a screen of its own rather than a control on
238 + /// the screen it edits. Add is absent for the same reason.
239 + fn milestones(
240 + project: ProjectId,
241 + all: &[Milestone],
242 + tasks: &[Task],
243 + show_completed: bool,
244 + ) -> Vec<Node> {
245 + let (open, done): (Vec<&Milestone>, Vec<&Milestone>) = all
246 + .iter()
247 + .partition(|milestone| milestone.status != MilestoneStatus::Completed);
248 +
249 + let mut out = vec![Node::section("Milestones")];
250 + if all.is_empty() {
251 + out.push(Node::text("No milestones yet"));
252 + return out;
253 + }
254 +
255 + let of = open.len();
256 + out.push(Node::list(open.iter().enumerate().map(
257 + |(at, milestone)| milestone_row(project, milestone, tasks, at, of),
258 + )));
259 +
260 + if !done.is_empty() {
261 + // The disclosure is an address, so an expanded dashboard survives a
262 + // reload and can be linked to. `showCompletedMilestones` in the JS is
263 + // module state that a re-render throws away.
264 + out.push(Node::act(
265 + if show_completed {
266 + "Hide completed".to_owned()
267 + } else {
268 + format!("Show {} completed", done.len())
269 + },
270 + filtered_by(
271 + Action::get(format!("/projects/{project}/dashboard")),
272 + "completed",
273 + !show_completed,
274 + ),
275 + ));
276 + if show_completed {
277 + out.push(Node::list(done.iter().map(|milestone| {
278 + Row::new(&milestone.name)
279 + .token(Tag::badge("Complete").tone(makeover_layout::Tone::Success))
280 + })));
281 + }
282 + }
283 +
284 + out
285 + }
286 +
287 + /// One column, as a region of its own.
288 + ///
289 + /// # The arrangement finding
290 + ///
291 + /// **Four peer columns are neither of the two arrangements.** `Arrangement` is
292 + /// `ListDetail` or `SidebarContent`, taken from what the two webview apps do,
293 + /// and this screen does a third thing: four equal panes side by side under a
294 + /// band, none of which chooses what another shows.
295 + ///
296 + /// The regions themselves are fine — a `Pane` each, and `Slot` takes as many as
297 + /// it is given — so the screen renders. What is wrong is that it has to claim
298 + /// `ListDetail` while being nothing of the sort, and a renderer that laid out
299 + /// list-detail faithfully would put the emails pane where the detail goes.
300 + ///
301 + /// Not filed as its own task. `Arrangement`'s docs say the two members came from
302 + /// measuring the two apps and that discovering the layer missing after the
303 + /// renderers exist is a redesign; this is the first counter-example and one
304 + /// counter-example is not a member. It is noted on the `Region::Bespoke`
305 + /// reasoning instead: a dashboard is a candidate for the app owning its own
306 + /// arrangement, the way it owns the heatmap.
307 + fn column(id: &str, title: &str, body: Node) -> Slot {
308 + Slot::new(id, RegionKind::Pane)
309 + .with(Node::section(title))
310 + .with(body)
311 + }
312 +
313 + /// The whole dashboard.
314 + fn screen(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Screen, RouteError> {
315 + let project: Project = state
316 + .projects
317 + .get_by_id(id, DESKTOP_USER_ID)
318 + .map_err(|error| RouteError::internal(error.to_string()))?
319 + .ok_or_else(|| RouteError::not_found("no such project"))?;
320 +
321 + let internal = |error: goingson_core::CoreError| RouteError::internal(error.to_string());
322 + let tasks = state
323 + .tasks
324 + .list_by_project(DESKTOP_USER_ID, id)
325 + .map_err(internal)?;
326 + let events = state
327 + .events
328 + .list_by_project(DESKTOP_USER_ID, id)
329 + .map_err(internal)?;
330 + let emails = state
331 + .emails
332 + .list_by_project(DESKTOP_USER_ID, id)
333 + .map_err(internal)?;
334 + let files = state
335 + .attachments
336 + .list_for_project(id, DESKTOP_USER_ID)
337 + .map_err(internal)?;
338 + let all_milestones = state
339 + .milestones
340 + .list_by_project(id, DESKTOP_USER_ID)
341 + .map_err(internal)?;
342 +
343 + let band = Slot::new("dashboard-band", RegionKind::Band)
344 + .with(Node::page(&project.name))
345 + .with(Node::Token(Tag::badge(type_label(&project.project_type))))
346 + .with(Node::Token(
347 + Tag::badge(project.status.as_str()).tone(status_tone(&project.status)),
348 + ))
349 + .with(Node::act(
350 + "Back to projects",
351 + Action::get(format!("/projects/{id}")),
352 + ));
353 +
354 + Ok(Screen::list_detail("Project", false)
355 + .with(band)
356 + .with(
357 + Slot::new("dashboard-milestones", RegionKind::Pane).extend(milestones(
358 + id,
359 + &all_milestones,
360 + &tasks,
361 + show_completed,
362 + )),
363 + )
364 + .with(column("dashboard-tasks", "Tasks", tasks_column(&tasks)))
365 + .with(column("dashboard-events", "Events", events_column(&events)))
366 + .with(column("dashboard-emails", "Emails", emails_column(&emails)))
367 + .with(column(
368 + "dashboard-attachments",
369 + "Attachments",
370 + attachments_column(&files),
371 + )))
372 + }
373 +
374 + /// Whether a param is on.
375 + fn flag(params: &quasi_router::Params, name: &str) -> bool {
376 + matches!(params.get(name), Some("1" | "true"))
377 + }
378 +
379 + /// The dashboard.
380 + fn dashboard(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
381 + let id = project_id(&params)?;
382 + Ok(screen(state, id, flag(&params, "completed"))?.into())
383 + }
384 +
385 + /// Answer a write with the dashboard it happened on, re-read.
386 + fn wrote(state: &AppState, id: ProjectId, show_completed: bool) -> Result<Response, RouteError> {
387 + Ok(screen(state, id, show_completed)?.into())
388 + }
389 +
390 + /// The milestone a route was addressed at.
391 + fn milestone_id(params: &quasi_router::Params) -> Result<goingson_core::MilestoneId, RouteError> {
392 + let raw = params
393 + .get("milestone")
394 + .ok_or_else(|| RouteError::not_found("no milestone id"))?;
395 + Ok(goingson_core::MilestoneId::from(
396 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a milestone id"))?,
397 + ))
398 + }
399 +
400 + /// Move a milestone one place up or down among the open ones.
401 + ///
402 + /// `reorder` takes the whole order rather than a swap, so the handler reads the
403 + /// current order, moves one, and writes it back. The order it writes is every
404 + /// milestone, completed ones included: they carry positions too, and sending
405 + /// only the open ones would silently renumber the rest.
406 + fn move_milestone(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
407 + let project = project_id(&params)?;
408 + let target = milestone_id(&params)?;
409 + let by: i32 = params
410 + .get("by")
411 + .and_then(|raw| raw.parse().ok())
412 + .filter(|by| *by == -1 || *by == 1)
413 + .ok_or_else(|| RouteError::not_found("move by -1 or 1"))?;
414 +
415 + let mut order: Vec<goingson_core::MilestoneId> = state
416 + .milestones
417 + .list_by_project(project, DESKTOP_USER_ID)
418 + .map_err(|error| RouteError::internal(error.to_string()))?
419 + .iter()
420 + .map(|milestone| milestone.id)
421 + .collect();
422 +
423 + let at = order
424 + .iter()
425 + .position(|id| *id == target)
426 + .ok_or_else(|| RouteError::not_found("no such milestone"))?;
427 + let to = at as i32 + by;
428 + // Off either end is a no-op rather than an error: the control that sent it
429 + // is disabled, so arriving here means a stale screen, and the right answer
430 + // to a stale screen is a fresh one.
431 + if to >= 0 && (to as usize) < order.len() {
432 + order.swap(at, to as usize);
433 + state
434 + .milestones
435 + .reorder(project, DESKTOP_USER_ID, &order)
436 + .map_err(|error| RouteError::internal(error.to_string()))?;
437 + }
438 +
439 + wrote(state, project, flag(&params, "completed"))
440 + }
441 +
442 + /// Delete a milestone.
443 + fn delete_milestone(
444 + state: &AppState,
445 + params: quasi_router::Params,
446 + ) -> Result<Response, RouteError> {
447 + let project = project_id(&params)?;
448 + let deleted = state
449 + .milestones
450 + .delete(milestone_id(&params)?, DESKTOP_USER_ID)
451 + .map_err(|error| RouteError::internal(error.to_string()))?;
452 + if !deleted {
453 + return Err(RouteError::not_found("no such milestone"));
454 + }
455 + wrote(state, project, flag(&params, "completed"))
456 + }
457 +
458 + /// The dashboard's routes.
459 + #[must_use]
460 + pub(super) fn routes(router: Router<AppState>) -> Router<AppState> {
461 + router
462 + .get("/projects/:id/dashboard", dashboard)
463 + .post("/projects/:id/milestones/:milestone/move", move_milestone)
464 + .post(
465 + "/projects/:id/milestones/:milestone/delete",
466 + delete_milestone,
467 + )
468 + }
@@ -1,0 +1,406 @@
1 + //! The project dashboard, driven through the router against a real database.
2 +
3 + use std::sync::Arc;
4 +
5 + use goingson_core::{
6 + MilestoneStatus, NewMilestone, NewProject, NewTask, ProjectId, ProjectStatus, ProjectType,
7 + };
8 + use quasi_http::Render as _;
9 + use quasi_router::{Method, Params, Response};
10 +
11 + use crate::quasi::router;
12 + use crate::state::{AppState, DESKTOP_USER_ID};
13 +
14 + async fn state() -> Arc<AppState> {
15 + let (state, _) = crate::test_utils::setup_test_state().await;
16 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
17 + state
18 + .db
19 + .conn()
20 + .unwrap()
21 + .execute(
22 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
23 + VALUES (?, ?, ?, ?, ?)",
24 + rusqlite::params![
25 + DESKTOP_USER_ID.to_string(),
26 + "desktop@localhost",
27 + "x",
28 + "Desktop User",
29 + &now,
30 + ],
31 + )
32 + .unwrap();
33 + state
34 + }
35 +
36 + fn project(state: &AppState) -> ProjectId {
37 + state
38 + .projects
39 + .create(
40 + DESKTOP_USER_ID,
41 + NewProject {
42 + name: "Ported".to_owned(),
43 + description: String::new(),
44 + project_type: ProjectType::SideProject,
45 + status: ProjectStatus::Active,
46 + },
47 + )
48 + .unwrap()
49 + .id
50 + }
51 +
52 + fn milestone(state: &AppState, project: ProjectId, name: &str) -> goingson_core::Milestone {
53 + state
54 + .milestones
55 + .create(
56 + DESKTOP_USER_ID,
57 + NewMilestone {
58 + project_id: project,
59 + name: name.to_owned(),
60 + description: String::new(),
61 + position: 0,
62 + target_date: None,
63 + },
64 + )
65 + .unwrap()
66 + }
67 +
68 + fn html(response: Response) -> String {
69 + match response {
70 + Response::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
71 + Response::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
72 + }
73 + }
74 +
75 + fn get(state: &AppState, path: &str, params: Params) -> Response {
76 + router()
77 + .handle(state, Method::Get, path, params)
78 + .expect("the route answers")
79 + }
80 +
81 + fn post(state: &AppState, path: &str, params: Params) -> Response {
82 + router()
83 + .handle(state, Method::Post, path, params)
84 + .expect("the route answers")
85 + }
86 +
87 + fn dashboard(state: &AppState, project: ProjectId) -> String {
88 + html(get(
89 + state,
90 + &format!("/projects/{project}/dashboard"),
91 + Params::new(),
92 + ))
93 + }
94 +
95 + #[tokio::test]
96 + async fn an_empty_dashboard_says_so_in_every_column() {
97 + let state = state().await;
98 + let project = project(&state);
99 + let page = dashboard(&state, project);
100 +
101 + // Four columns, each saying what it has none of. An empty column that
102 + // renders nothing reads as a column that failed to load.
103 + assert!(page.contains("No tasks linked yet."));
104 + assert!(page.contains("No events linked yet."));
105 + assert!(page.contains("No emails linked yet."));
106 + assert!(page.contains("No attachments yet."));
107 + assert!(page.contains("No milestones yet"));
108 + }
109 +
110 + #[tokio::test]
111 + async fn a_dashboard_of_nothing_is_still_four_named_regions() {
112 + let state = state().await;
113 + let project = project(&state);
114 + let page = dashboard(&state, project);
115 +
116 + for column in ["Tasks", "Events", "Emails", "Attachments"] {
117 + assert!(page.contains(column), "{column} is named");
118 + }
119 + }
120 +
121 + #[tokio::test]
122 + async fn a_missing_project_is_a_not_found_rather_than_a_panic() {
123 + let state = state().await;
124 + let error = router()
125 + .handle(
126 + &state,
127 + Method::Get,
128 + &format!("/projects/{}/dashboard", uuid::Uuid::nil()),
129 + Params::new(),
130 + )
131 + .expect_err("no such project");
132 + assert_eq!(error.class.http_status(), 404);
133 + }
134 +
135 + #[tokio::test]
136 + async fn the_dashboard_route_is_not_swallowed_by_the_detail_route() {
137 + // `/projects/:id` and `/projects/:id/dashboard` are different lengths so
138 + // they cannot collide, but the detail route is registered first and this is
139 + // the assertion that says the composition order does not matter.
140 + let state = state().await;
141 + let project = project(&state);
142 +
143 + let detail = get(&state, &format!("/projects/{project}"), Params::new());
144 + assert_eq!(detail.target(), Some("projects-detail"));
145 +
146 + let Response::Screen(_) = get(
147 + &state,
148 + &format!("/projects/{project}/dashboard"),
149 + Params::new(),
150 + ) else {
151 + panic!("the dashboard answers with a screen");
152 + };
153 + }
154 +
155 + #[tokio::test]
156 + async fn all_tasks_complete_is_a_different_thing_from_no_tasks() {
157 + let state = state().await;
158 + let project = project(&state);
159 + let task = state
160 + .tasks
161 + .create(
162 + DESKTOP_USER_ID,
163 + NewTask::builder("Done")
164 + .title("Done")
165 + .project_id(project)
166 + .build(),
167 + )
168 + .unwrap();
169 + state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
170 +
171 + let page = dashboard(&state, project);
172 + assert!(page.contains("All tasks complete."));
173 + assert!(!page.contains("No tasks linked yet."));
174 + }
175 +
176 + #[tokio::test]
177 + async fn a_linked_task_addresses_the_task_overview() {
178 + // The two described screens meet: a dashboard row opens the overview this
179 + // same router answers. Nothing wires them together beyond the address.
180 + let state = state().await;
181 + let project = project(&state);
182 + let task = state
183 + .tasks
184 + .create(
185 + DESKTOP_USER_ID,
186 + NewTask::builder("Live")
187 + .title("Live")
188 + .project_id(project)
189 + .build(),
190 + )
191 + .unwrap();
192 +
193 + let page = dashboard(&state, project);
194 + assert!(page.contains(&format!("hx-get=\"/tasks/{}\"", task.id)));
195 +
196 + // And it answers.
197 + let Response::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()) else {
198 + panic!("the overview answers");
199 + };
200 + }
201 +
202 + #[tokio::test]
203 + async fn a_milestone_carries_its_progress_as_text_because_nothing_names_a_bar() {
204 + // The proportion finding's third and fourth call sites. makeover-layout
205 + // `d0b58239`.
206 + let state = state().await;
207 + let project = project(&state);
208 + let target = milestone(&state, project, "Phase one");
209 +
210 + let done = state
211 + .tasks
212 + .create(
213 + DESKTOP_USER_ID,
214 + NewTask::builder("Done")
215 + .title("Done")
216 + .project_id(project)
217 + .milestone_id(target.id)
218 + .build(),
219 + )
220 + .unwrap();
221 + state.tasks.complete(done.id, DESKTOP_USER_ID).unwrap();
222 +
223 + let page = dashboard(&state, project);
224 + assert!(page.contains("Phase one"));
225 + assert!(page.contains("1/1"));
226 + assert!(!page.contains("progress-fill"));
227 + }
228 +
229 + #[tokio::test]
230 + async fn the_reorder_controls_are_disabled_at_the_ends_rather_than_hidden() {
231 + // A control that vanishes at the edge of a list is a control the user has to
232 + // discover twice. `Act::disabled` says what the JS's hidden spacer means.
233 + let state = state().await;
234 + let project = project(&state);
235 + milestone(&state, project, "First");
236 + milestone(&state, project, "Second");
237 +
238 + let page = dashboard(&state, project);
239 + assert!(page.contains("Move up"));
240 + assert!(page.contains("Move down"));
241 + assert!(page.contains("disabled"));
242 + }
243 +
244 + #[tokio::test]
245 + async fn moving_a_milestone_changes_the_order_it_comes_back_in() {
246 + let state = state().await;
247 + let project = project(&state);
248 + let first = milestone(&state, project, "First");
249 + milestone(&state, project, "Second");
250 +
251 + let before = dashboard(&state, project);
252 + assert!(before.find("First").unwrap() < before.find("Second").unwrap());
253 +
254 + let page = html(post(
255 + &state,
256 + &format!("/projects/{project}/milestones/{}/move", first.id),
257 + Params::new().with("by", "1"),
258 + ));
259 + assert!(page.find("Second").unwrap() < page.find("First").unwrap());
260 + }
261 +
262 + #[tokio::test]
263 + async fn moving_off_the_end_is_a_fresh_screen_rather_than_an_error() {
264 + // The control that would send this is disabled, so arriving here means a
265 + // stale screen, and the answer to a stale screen is a current one.
266 + let state = state().await;
267 + let project = project(&state);
268 + let first = milestone(&state, project, "First");
269 +
270 + let page = html(post(
271 + &state,
272 + &format!("/projects/{project}/milestones/{}/move", first.id),
273 + Params::new().with("by", "-1"),
274 + ));
275 + assert!(page.contains("First"));
276 + }
277 +
278 + #[tokio::test]
279 + async fn a_move_that_is_not_one_step_is_refused() {
280 + let state = state().await;
281 + let project = project(&state);
282 + let first = milestone(&state, project, "First");
283 +
284 + let error = router()
285 + .handle(
286 + &state,
287 + Method::Post,
288 + &format!("/projects/{project}/milestones/{}/move", first.id),
289 + Params::new().with("by", "7"),
290 + )
291 + .expect_err("only one step at a time");
292 + assert_eq!(error.class.http_status(), 404);
293 + }
294 +
295 + #[tokio::test]
296 + async fn deleting_a_milestone_takes_it_off_the_dashboard() {
297 + let state = state().await;
298 + let project = project(&state);
299 + let going = milestone(&state, project, "Going");
300 + milestone(&state, project, "Staying");
301 +
302 + let page = html(post(
303 + &state,
304 + &format!("/projects/{project}/milestones/{}/delete", going.id),
305 + Params::new(),
306 + ));
307 + assert!(!page.contains("Going"));
308 + assert!(page.contains("Staying"));
309 + }
310 +
311 + #[tokio::test]
312 + async fn the_completed_disclosure_is_an_address_not_module_state() {
313 + // `showCompletedMilestones` is a module variable in the JS that a re-render
314 + // throws away. Here the expanded dashboard has its own address, so it
315 + // survives a reload and can be linked to.
316 + let state = state().await;
317 + let project = project(&state);
318 + milestone(&state, project, "Open one");
319 + let done = milestone(&state, project, "Finished");
320 + state
321 + .milestones
322 + .update(
323 + done.id,
324 + DESKTOP_USER_ID,
325 + "Finished",
326 + "",
327 + None,
328 + &MilestoneStatus::Completed,
329 + )
330 + .unwrap();
331 +
332 + let collapsed = dashboard(&state, project);
333 + assert!(collapsed.contains("Show 1 completed"));
334 + assert!(!collapsed.contains("Complete<"));
335 +
336 + let expanded = html(get(
337 + &state,
338 + &format!("/projects/{project}/dashboard"),
339 + Params::new().with("completed", "1"),
340 + ));
341 + assert!(expanded.contains("Hide completed"));
342 + assert!(expanded.contains("Finished"));
343 + }
344 +
345 + #[tokio::test]
346 + async fn a_write_answers_under_the_disclosure_it_carried() {
347 + let state = state().await;
348 + let project = project(&state);
349 + let going = milestone(&state, project, "Going");
350 + let done = milestone(&state, project, "Finished");
351 + state
352 + .milestones
353 + .update(
354 + done.id,
355 + DESKTOP_USER_ID,
356 + "Finished",
357 + "",
358 + None,
359 + &MilestoneStatus::Completed,
360 + )
361 + .unwrap();
362 +
363 + let page = html(post(
364 + &state,
365 + &format!("/projects/{project}/milestones/{}/delete", going.id),
366 + Params::new().with("completed", "1"),
367 + ));
368 + // Still expanded afterwards. Dropping it here is how a delete reads as
369 + // having collapsed the section.
370 + assert!(page.contains("Hide completed"));
371 + assert!(page.contains("Finished"));
372 + }
373 +
374 + #[tokio::test]
375 + async fn the_attachment_controls_are_absent_because_they_are_not_routes() {
376 + // The finding. `attachments.pickAndAttach` opens the OS file picker, which
377 + // is neither a route this app answers nor an external address a browser
378 + // navigates to. Left out rather than pointed at a route that cannot exist.
379 + let state = state().await;
380 + let project = project(&state);
381 + let page = dashboard(&state, project);
382 +
383 + assert!(page.contains("No attachments yet."));
384 + assert!(!page.contains("Attach File"));
385 + }
386 +
387 + #[tokio::test]
388 + async fn a_project_name_cannot_become_markup() {
389 + let state = state().await;
390 + let project = state
391 + .projects
392 + .create(
393 + DESKTOP_USER_ID,
394 + NewProject {
395 + name: "<script>alert(1)</script>".to_owned(),
396 + description: String::new(),
397 + project_type: ProjectType::SideProject,
398 + status: ProjectStatus::Active,
399 + },
400 + )
401 + .unwrap();
402 +
403 + let page = dashboard(&state, project.id);
404 + assert!(!page.contains("<script>alert"));
405 + assert!(page.contains("&lt;script&gt;"));
406 + }