max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+561 insertions,
-27 deletions
| @@ -3398,9 +3398,9 @@ | |||
| 3398 | 3398 | ||
| 3399 | 3399 | [[package]] | |
| 3400 | 3400 | name = "makeover-build" | |
| 3401 | - | version = "0.35.0" | |
| 3401 | + | version = "0.36.0" | |
| 3402 | 3402 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3403 | - | checksum = "a97084502058507e10cad14d490963d74ad453e654d4fe3b3e83fbb6df5a64b8" | |
| 3403 | + | checksum = "fcfae38007d878c0e97774066bf253b77d32ae2c10e278bbf6c785b261bef69e" | |
| 3404 | 3404 | dependencies = [ | |
| 3405 | 3405 | "makeover", | |
| 3406 | 3406 | "makeover-geometry", | |
| @@ -3415,15 +3415,15 @@ | |||
| 3415 | 3415 | ||
| 3416 | 3416 | [[package]] | |
| 3417 | 3417 | name = "makeover-layout" | |
| 3418 | - | version = "0.24.0" | |
| 3418 | + | version = "0.25.0" | |
| 3419 | 3419 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3420 | - | checksum = "d7e8857e635b4b23c21bbea360a4b33eefb4c6ff252a4cf4b3564a55de55d19e" | |
| 3420 | + | checksum = "f7480bdecd1ccca99dbfd744327f5598d67dd9be3157c48928466b5e20d4aed3" | |
| 3421 | 3421 | ||
| 3422 | 3422 | [[package]] | |
| 3423 | 3423 | name = "makeover-touch" | |
| 3424 | - | version = "0.16.0" | |
| 3424 | + | version = "0.17.0" | |
| 3425 | 3425 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3426 | - | checksum = "af562d05e17947bb8fe7f3ce45d8f9b803627ae14b8931ebf578e12ddae85bdc" | |
| 3426 | + | checksum = "bd1cac03a0e0b912a16f07044a79099c5bf8be3741403cd2b980d0aa4e57adac" | |
| 3427 | 3427 | dependencies = [ | |
| 3428 | 3428 | "makeover-geometry", | |
| 3429 | 3429 | "makeover-layout", | |
| @@ -3431,9 +3431,9 @@ | |||
| 3431 | 3431 | ||
| 3432 | 3432 | [[package]] | |
| 3433 | 3433 | name = "makeover-tui" | |
| 3434 | - | version = "0.22.0" | |
| 3434 | + | version = "0.23.0" | |
| 3435 | 3435 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3436 | - | checksum = "027e2be8f603b034a809ac2a7be7dc055ac7d3a864a3fa6f1a4418b8d976ad4f" | |
| 3436 | + | checksum = "d2c1f0eb73f3d89511a1a731b0f7e6705c39c7e06a7c944fcde1cbc7aebe5604" | |
| 3437 | 3437 | dependencies = [ | |
| 3438 | 3438 | "makeover", | |
| 3439 | 3439 | "makeover-layout", | |
| @@ -3442,9 +3442,9 @@ | |||
| 3442 | 3442 | ||
| 3443 | 3443 | [[package]] | |
| 3444 | 3444 | name = "makeover-webview" | |
| 3445 | - | version = "0.42.0" | |
| 3445 | + | version = "0.43.0" | |
| 3446 | 3446 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3447 | - | checksum = "6937a91744ab84f225f36656819a9db4b0a08d5adfb65a7f71f0b03b1281cd0e" | |
| 3447 | + | checksum = "1fc6f622ba5546e4ce0b8393addbb23c905dad5b1888ee7b588f04c696fecca4" | |
| 3448 | 3448 | dependencies = [ | |
| 3449 | 3449 | "makeover-geometry", | |
| 3450 | 3450 | "makeover-layout", | |
| @@ -4651,7 +4651,7 @@ | |||
| 4651 | 4651 | ||
| 4652 | 4652 | [[package]] | |
| 4653 | 4653 | name = "quasi-http" | |
| 4654 | - | version = "0.6.0" | |
| 4654 | + | version = "0.7.0" | |
| 4655 | 4655 | dependencies = [ | |
| 4656 | 4656 | "form_urlencoded", | |
| 4657 | 4657 | "http", | |
| @@ -4660,14 +4660,14 @@ | |||
| 4660 | 4660 | ||
| 4661 | 4661 | [[package]] | |
| 4662 | 4662 | name = "quasi-router" | |
| 4663 | - | version = "0.6.0" | |
| 4663 | + | version = "0.7.0" | |
| 4664 | 4664 | dependencies = [ | |
| 4665 | 4665 | "makeover-layout", | |
| 4666 | 4666 | ] | |
| 4667 | 4667 | ||
| 4668 | 4668 | [[package]] | |
| 4669 | 4669 | name = "quasi-tauri" | |
| 4670 | - | version = "0.6.0" | |
| 4670 | + | version = "0.7.0" | |
| 4671 | 4671 | dependencies = [ | |
| 4672 | 4672 | "http", | |
| 4673 | 4673 | "quasi-http", | |
| @@ -4677,7 +4677,7 @@ | |||
| 4677 | 4677 | ||
| 4678 | 4678 | [[package]] | |
| 4679 | 4679 | name = "quasi-webview" | |
| 4680 | - | version = "0.6.0" | |
| 4680 | + | version = "0.7.0" | |
| 4681 | 4681 | dependencies = [ | |
| 4682 | 4682 | "docengine", | |
| 4683 | 4683 | "makeover-layout", | |
| @@ -8375,15 +8375,15 @@ | |||
| 8375 | 8375 | ||
| 8376 | 8376 | [[patch.unused]] | |
| 8377 | 8377 | name = "quasi-axum" | |
| 8378 | - | version = "0.6.0" | |
| 8378 | + | version = "0.7.0" | |
| 8379 | 8379 | ||
| 8380 | 8380 | [[patch.unused]] | |
| 8381 | 8381 | name = "quasi-basics" | |
| 8382 | - | version = "0.6.0" | |
| 8382 | + | version = "0.7.0" | |
| 8383 | 8383 | ||
| 8384 | 8384 | [[patch.unused]] | |
| 8385 | 8385 | name = "quasi-immediate" | |
| 8386 | - | version = "0.6.0" | |
| 8386 | + | version = "0.7.0" | |
| 8387 | 8387 | ||
| 8388 | 8388 | [[patch.unused]] | |
| 8389 | 8389 | name = "quasi-store" |
| @@ -95,7 +95,7 @@ | |||
| 95 | 95 | notify = "8.2" | |
| 96 | 96 | notify-debouncer-mini = "0.7" | |
| 97 | 97 | makeover = "2.5" | |
| 98 | - | makeover-tui = "0.22.0" | |
| 98 | + | makeover-tui = "0.23.0" | |
| 99 | 99 | toml = "1.1" | |
| 100 | 100 | ||
| 101 | 101 | # Enums |
| @@ -18,12 +18,12 @@ | |||
| 18 | 18 | # Materialises all three generated files: themes/, geometry.css, layout.css, | |
| 19 | 19 | # and holds both drift checks this build.rs used to carry itself: touch density | |
| 20 | 20 | # as of 0.17, breakpoints as of 0.18. | |
| 21 | - | makeover-build = "0.35.0" | |
| 21 | + | makeover-build = "0.36.0" | |
| 22 | 22 | # The table CSS is generated here too: the columns are this app's, so the | |
| 23 | 23 | # shared helper cannot know them, but the tracks and the narrowing rules come | |
| 24 | 24 | # from the description rather than from hand-written nth-child cuts. | |
| 25 | - | makeover-webview = "0.42.0" | |
| 26 | - | makeover-layout = "0.24.0" | |
| 25 | + | makeover-webview = "0.43.0" | |
| 26 | + | makeover-layout = "0.25.0" | |
| 27 | 27 | # Width. Direct rather than through makeover-webview, because the narrow table | |
| 28 | 28 | # pass keys off SizeClass::Compact and a boundary reached transitively is a | |
| 29 | 29 | # boundary nobody pinned. | |
| @@ -45,10 +45,10 @@ | |||
| 45 | 45 | # dependency's source even with the feature off -- so a public clone could not | |
| 46 | 46 | # build at all against a URL it could not reach. quasi went public that day and | |
| 47 | 47 | # the URL moved here with it. | |
| 48 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true } | |
| 49 | - | quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true } | |
| 50 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true } | |
| 51 | - | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true } | |
| 48 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true } | |
| 49 | + | quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true } | |
| 50 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true } | |
| 51 | + | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.7", optional = true } | |
| 52 | 52 | ||
| 53 | 53 | # Tauri | |
| 54 | 54 | tauri = { workspace = true, features = ["image-png", "devtools"] } | |
| @@ -112,8 +112,8 @@ | |||
| 112 | 112 | # The forms emitter and the description it renders. Runtime deps, not build: | |
| 113 | 113 | # form markup depends on the value, the error and the options at the moment a | |
| 114 | 114 | # modal opens, so it cannot be materialised the way the stylesheet is. | |
| 115 | - | makeover-webview = "0.42.0" | |
| 116 | - | makeover-layout = "0.24.0" | |
| 115 | + | makeover-webview = "0.43.0" | |
| 116 | + | makeover-layout = "0.25.0" | |
| 117 | 117 | # Browser opening | |
| 118 | 118 | open = { workspace = true } | |
| 119 | 119 |
| @@ -34,6 +34,7 @@ | |||
| 34 | 34 | ||
| 35 | 35 | use crate::state::AppState; | |
| 36 | 36 | ||
| 37 | + | pub mod board; | |
| 37 | 38 | pub mod contacts; | |
| 38 | 39 | pub mod day_planning; | |
| 39 | 40 | pub mod emails; | |
| @@ -56,6 +57,7 @@ | |||
| 56 | 57 | let router = monthly_review::routes(router); | |
| 57 | 58 | let router = problems::routes(router); | |
| 58 | 59 | let router = day_planning::routes(router); | |
| 60 | + | let router = board::routes(router); | |
| 59 | 61 | emails::routes(router) | |
| 60 | 62 | } | |
| 61 | 63 |
| @@ -1,0 +1,312 @@ | |||
| 1 | + | //! The task board, described rather than built. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! Eleventh port, and the second that needed the vocabulary to grow. The day | |
| 6 | + | //! view took `makeover-layout` 0.24.0's `Track`; this one took 0.25.0's | |
| 7 | + | //! [`RegionKind::Columns`]. | |
| 8 | + | //! | |
| 9 | + | //! `frontend/js/tasks-kanban.js` (179) and `task-board.js` (109) were on the | |
| 10 | + | //! same "cannot be described" list as the timeline, in the same sentence. The | |
| 11 | + | //! measurement, taken before the member was added: every card fact was already | |
| 12 | + | //! sayable. A title is a row's primary, the project and the due date are meta, | |
| 13 | + | //! the blocked and unblocks markers are tokens, subtask progress is a `Meter`, | |
| 14 | + | //! opening a card is `activate` and the context menu is `menu`. A column is a | |
| 15 | + | //! heading, a count and a list. | |
| 16 | + | //! | |
| 17 | + | //! What nothing could say was that the three columns are **peers**. `Arrangement` | |
| 18 | + | //! offered list-detail and sidebar-content, and a board described as either is a | |
| 19 | + | //! lie about the screen. One member. | |
| 20 | + | //! | |
| 21 | + | //! # The shape | |
| 22 | + | //! | |
| 23 | + | //! - `GET /board` — the three columns. | |
| 24 | + | //! - `POST /board/{id}/status` — move a card, carrying `to`. | |
| 25 | + | //! | |
| 26 | + | //! # Dragging | |
| 27 | + | //! | |
| 28 | + | //! Never a blocker, and worth writing down because it looks like one. A drop's | |
| 29 | + | //! *effect* is `set status to Started`: a discrete action with a discrete | |
| 30 | + | //! argument, which the vocabulary has always been able to say. The drag is the | |
| 31 | + | //! affordance, it is presentation, and the host keeps it — the same answer the | |
| 32 | + | //! day view's paint interaction got, arrived at from the other side. | |
| 33 | + | //! | |
| 34 | + | //! So each card offers its two moves as acts. A webview may wire those to a | |
| 35 | + | //! drop target and a terminal may bind them to keys; both are honouring the | |
| 36 | + | //! same description. | |
| 37 | + | //! | |
| 38 | + | //! # `to` and not `status` | |
| 39 | + | //! | |
| 40 | + | //! The target state travels as `to` because `status` is what a column *is*. | |
| 41 | + | //! Second screen to hit that (quasicoherent `53936dd4`, closed), and the | |
| 42 | + | //! problems inbox was the first. | |
| 43 | + | ||
| 44 | + | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 45 | + | // plain `fn(&S, Request)` pointer, so the signature is the router's. | |
| 46 | + | #![allow(clippy::needless_pass_by_value)] | |
| 47 | + | ||
| 48 | + | use goingson_core::{Priority, Task, TaskId, TaskStatus, UpdateTask}; | |
| 49 | + | use makeover_layout::{Heading, Tone}; | |
| 50 | + | use quasi_router::screen::{Act, Meter, Row, Tag}; | |
| 51 | + | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 52 | + | ||
| 53 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 54 | + | ||
| 55 | + | #[cfg(test)] | |
| 56 | + | mod tests; | |
| 57 | + | ||
| 58 | + | /// The three columns, in the order the board reads. | |
| 59 | + | /// | |
| 60 | + | /// `tasks-kanban.js`'s `COLUMNS`, and the same order. `Deleted` is not a column | |
| 61 | + | /// because a deleted task is not on the board; the JS drops it by only grouping | |
| 62 | + | /// the three it knows. | |
| 63 | + | const COLUMNS: [(TaskStatus, &str, &str); 3] = [ | |
| 64 | + | (TaskStatus::Pending, "pending", "Pending"), | |
| 65 | + | (TaskStatus::Started, "started", "Started"), | |
| 66 | + | (TaskStatus::Completed, "done", "Completed"), | |
| 67 | + | ]; | |
| 68 | + | ||
| 69 | + | /// The tone a priority wears. `tasks.rs`'s `priority_tone`, which is the same | |
| 70 | + | /// mapping and is not re-derived here on purpose. | |
| 71 | + | const fn priority_tone(priority: &Priority) -> Tone { | |
| 72 | + | match priority { | |
| 73 | + | Priority::High => Tone::Danger, | |
| 74 | + | Priority::Medium => Tone::Warning, | |
| 75 | + | Priority::Low => Tone::Neutral, | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// The task a route was addressed at. | |
| 80 | + | fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> { | |
| 81 | + | let raw = request | |
| 82 | + | .captures | |
| 83 | + | .get("id") | |
| 84 | + | .ok_or_else(|| RouteError::not_found("no task id"))?; | |
| 85 | + | Ok(TaskId::from( | |
| 86 | + | uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, | |
| 87 | + | )) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Where a card is being moved to. | |
| 91 | + | /// | |
| 92 | + | /// `to`, not `status`. Unparseable is a 404 rather than a silent no-op: a | |
| 93 | + | /// control naming a column that does not exist is a wiring mistake, and | |
| 94 | + | /// answering it with an unchanged board hides it. | |
| 95 | + | fn target(request: &quasi_router::Request) -> Result<TaskStatus, RouteError> { | |
| 96 | + | match request | |
| 97 | + | .payload | |
| 98 | + | .get("to") | |
| 99 | + | .or_else(|| request.carried.get("to")) | |
| 100 | + | { | |
| 101 | + | Some("Pending") => Ok(TaskStatus::Pending), | |
| 102 | + | Some("Started") => Ok(TaskStatus::Started), | |
| 103 | + | Some("Completed") => Ok(TaskStatus::Completed), | |
| 104 | + | _ => Err(RouteError::not_found("no such column")), | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// One task as a card. | |
| 109 | + | /// | |
| 110 | + | /// Every part of this existed before the board did, which is the measurement | |
| 111 | + | /// the kanban refusal never took. | |
| 112 | + | fn card(task: &Task) -> Row { | |
| 113 | + | let mut row = Row::new(&task.title).token( | |
| 114 | + | Tag::badge(match task.priority { | |
| 115 | + | Priority::High => "High", | |
| 116 | + | Priority::Medium => "Medium", | |
| 117 | + | Priority::Low => "Low", | |
| 118 | + | }) | |
| 119 | + | .tone(priority_tone(&task.priority)), | |
| 120 | + | ); | |
| 121 | + | ||
| 122 | + | if let Some(project) = &task.project_name { | |
| 123 | + | row = row.meta(project); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // The due date, and whether it has passed. Overdue is a judgment the app | |
| 127 | + | // makes and the renderer cannot, so it travels as a tone rather than as a | |
| 128 | + | // class the way `kanban-card-due.overdue` does. | |
| 129 | + | if let Some(due) = task.due { | |
| 130 | + | let overdue = due < chrono::Utc::now() && task.status != TaskStatus::Completed; | |
| 131 | + | row = row.token( | |
| 132 | + | Tag::badge( | |
| 133 | + | due.with_timezone(&chrono::Local) | |
| 134 | + | .format("%b %-d") | |
| 135 | + | .to_string(), | |
| 136 | + | ) | |
| 137 | + | .tone(if overdue { Tone::Danger } else { Tone::Neutral }), | |
| 138 | + | ); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Subtask progress. `Meter` says done-of-total; the JS says a percentage | |
| 142 | + | // width, which is the same fact already divided. Absent rather than empty | |
| 143 | + | // when a task has no subtasks, so a card does not carry a bar at zero. | |
| 144 | + | if task.subtask_count() > 0 { | |
| 145 | + | row = row.meter(Meter { | |
| 146 | + | done: u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX), | |
| 147 | + | total: u32::try_from(task.subtask_count()).unwrap_or(u32::MAX), | |
| 148 | + | tone: Tone::Success, | |
| 149 | + | label: None, | |
| 150 | + | }); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // The moves this card offers, which is what a drop does. Its own column is | |
| 154 | + | // left out: dropping a card where it already is is the one case | |
| 155 | + | // `onDrop` bails on, and offering it would be an act that does nothing. | |
| 156 | + | for (status, _, label) in COLUMNS { | |
| 157 | + | if status != task.status { | |
| 158 | + | row = row.act(Act::new( | |
| 159 | + | format!("Move to {label}"), | |
| 160 | + | Action::post(format!("/board/{}/status", task.id)).with("to", label), | |
| 161 | + | )); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | row.activate(Action::get(format!("/tasks/{}", task.id))) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// One column: its name, how many are in it, and the cards. | |
| 169 | + | fn column(state: &AppState, status: TaskStatus, id: &str, label: &str) -> Result<Slot, RouteError> { | |
| 170 | + | let tasks = state | |
| 171 | + | .tasks | |
| 172 | + | .list_all(DESKTOP_USER_ID) | |
| 173 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 174 | + | ||
| 175 | + | let rows: Vec<Row> = tasks | |
| 176 | + | .iter() | |
| 177 | + | .filter(|t| t.status == status) | |
| 178 | + | .map(card) | |
| 179 | + | .collect(); | |
| 180 | + | ||
| 181 | + | let mut slot = Slot::new(id, RegionKind::Pane).with(Node::Heading { | |
| 182 | + | level: Heading::Section, | |
| 183 | + | text: label.to_owned(), | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | // The count. A figure rather than the JS's bare span, because it is a | |
| 187 | + | // number with a caption and that is what a figure is. | |
| 188 | + | slot = slot.with(Node::Text { | |
| 189 | + | text: format!("{}", rows.len()), | |
| 190 | + | tone: Tone::Neutral, | |
| 191 | + | }); | |
| 192 | + | ||
| 193 | + | Ok(if rows.is_empty() { | |
| 194 | + | slot.with(Node::empty("No tasks")) | |
| 195 | + | } else { | |
| 196 | + | slot.with(Node::list(rows)) | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// The board itself, as one region of peer columns. | |
| 201 | + | fn board_region(state: &AppState) -> Result<Slot, RouteError> { | |
| 202 | + | let mut board = Slot::new("board", RegionKind::Columns); | |
| 203 | + | for (status, id, label) in COLUMNS { | |
| 204 | + | board = board.with(Node::Region(column(state, status, id, label)?)); | |
| 205 | + | } | |
| 206 | + | Ok(board) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// The whole board. | |
| 210 | + | fn board(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 211 | + | Ok(Screen::list_detail("Board", false) | |
| 212 | + | .with(board_region(state)?) | |
| 213 | + | .into()) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Move a card, and answer the board re-read. | |
| 217 | + | /// | |
| 218 | + | /// Re-read rather than patched, for the reason the problems triage is: the card | |
| 219 | + | /// leaves one column and joins another, and completing a recurring task creates | |
| 220 | + | /// its successor somewhere else on the board. Only the store knows what the | |
| 221 | + | /// board looks like afterwards. | |
| 222 | + | fn move_card(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 223 | + | let id = task_id(&request)?; | |
| 224 | + | let to = target(&request)?; | |
| 225 | + | ||
| 226 | + | let task = state | |
| 227 | + | .tasks | |
| 228 | + | .get_by_id(id, DESKTOP_USER_ID) | |
| 229 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 230 | + | .filter(|task| task.status != TaskStatus::Deleted) | |
| 231 | + | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 232 | + | ||
| 233 | + | // Already there. The JS bails here too, and it matters more through a route | |
| 234 | + | // than through a drop: a repeated POST must not complete a task twice and | |
| 235 | + | // mint a second recurrence. | |
| 236 | + | if task.status == to { | |
| 237 | + | return Ok(Response::fragment( | |
| 238 | + | "board", | |
| 239 | + | Node::Region(board_region(state)?), | |
| 240 | + | )); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Three different writes, which is what the JS does and is not incidental. | |
| 244 | + | // Starting stamps a start time and completing runs the recurrence rule, so | |
| 245 | + | // neither is "set the status column". | |
| 246 | + | let message = match to { | |
| 247 | + | TaskStatus::Started => { | |
| 248 | + | state | |
| 249 | + | .tasks | |
| 250 | + | .start(id, DESKTOP_USER_ID) | |
| 251 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 252 | + | "Task started." | |
| 253 | + | } | |
| 254 | + | TaskStatus::Completed => { | |
| 255 | + | // The timer stops with it, matching `complete_task`. A card | |
| 256 | + | // completed with a timer running would otherwise keep accruing. | |
| 257 | + | let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID); | |
| 258 | + | state | |
| 259 | + | .tasks | |
| 260 | + | .complete(id, DESKTOP_USER_ID) | |
| 261 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 262 | + | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 263 | + | "Task completed." | |
| 264 | + | } | |
| 265 | + | TaskStatus::Pending => { | |
| 266 | + | // `UpdateTask` replaces rather than patches, which is why the JS | |
| 267 | + | // resends every field on this path and why this does too. Built | |
| 268 | + | // from the task just read, so the only thing that changes is the | |
| 269 | + | // status; anything left out would be cleared, and moving a card | |
| 270 | + | // left is not a reason to lose its tags. | |
| 271 | + | state | |
| 272 | + | .tasks | |
| 273 | + | .update( | |
| 274 | + | id, | |
| 275 | + | DESKTOP_USER_ID, | |
| 276 | + | UpdateTask { | |
| 277 | + | project_id: task.project_id, | |
| 278 | + | milestone_id: task.milestone_id, | |
| 279 | + | contact_id: task.contact_id, | |
| 280 | + | title: task.title.clone(), | |
| 281 | + | description: task.description.clone(), | |
| 282 | + | status: TaskStatus::Pending, | |
| 283 | + | priority: task.priority, | |
| 284 | + | due: task.due, | |
| 285 | + | tags: task.tags.clone(), | |
| 286 | + | recurrence: task.recurrence, | |
| 287 | + | recurrence_rule: task.recurrence_rule.clone(), | |
| 288 | + | urgency: task.urgency, | |
| 289 | + | scheduled_start: task.scheduled_start, | |
| 290 | + | scheduled_duration: task.scheduled_duration, | |
| 291 | + | estimated_minutes: task.estimated_minutes, | |
| 292 | + | }, | |
| 293 | + | ) | |
| 294 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 295 | + | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 296 | + | "Task moved to Pending." | |
| 297 | + | } | |
| 298 | + | TaskStatus::Deleted => return Err(RouteError::not_found("no such column")), | |
| 299 | + | }; | |
| 300 | + | ||
| 301 | + | Ok( | |
| 302 | + | Response::fragment("board", Node::Region(board_region(state)?)) | |
| 303 | + | .toast(Tone::Success, message), | |
| 304 | + | ) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /// This screen's routes. | |
| 308 | + | pub fn routes(router: Router<AppState>) -> Router<AppState> { | |
| 309 | + | router | |
| 310 | + | .get("/board", board) | |
| 311 | + | .post("/board/{id}/status", move_card) | |
| 312 | + | } |
| @@ -1,0 +1,220 @@ | |||
| 1 | + | //! The board, driven through the router against a real database. | |
| 2 | + | //! | |
| 3 | + | //! The assertions worth reading are the ones about *peers*: that the three | |
| 4 | + | //! columns are one region of equals rather than a list and a detail, which is | |
| 5 | + | //! the single member `makeover-layout` 0.25.0 added for this screen. | |
| 6 | + | ||
| 7 | + | use std::sync::Arc; | |
| 8 | + | ||
| 9 | + | use goingson_core::{NewTask, Priority, TaskStatus}; | |
| 10 | + | use quasi_http::Serves as _; | |
| 11 | + | use quasi_router::{Outcome, Params, Request, Response}; | |
| 12 | + | ||
| 13 | + | use super::super::router; | |
| 14 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 15 | + | ||
| 16 | + | async fn state() -> Arc<AppState> { | |
| 17 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 18 | + | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 19 | + | state | |
| 20 | + | .db | |
| 21 | + | .conn() | |
| 22 | + | .unwrap() | |
| 23 | + | .execute( | |
| 24 | + | "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ | |
| 25 | + | VALUES (?, ?, ?, ?, ?)", | |
| 26 | + | rusqlite::params![ | |
| 27 | + | DESKTOP_USER_ID.to_string(), | |
| 28 | + | "desktop@localhost", | |
| 29 | + | "x", | |
| 30 | + | "Desktop User", | |
| 31 | + | &now, | |
| 32 | + | ], | |
| 33 | + | ) | |
| 34 | + | .unwrap(); | |
| 35 | + | state | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// A pending task, which is where every card starts. | |
| 39 | + | fn task(state: &AppState, title: &str) -> goingson_core::TaskId { | |
| 40 | + | state | |
| 41 | + | .tasks | |
| 42 | + | .create( | |
| 43 | + | DESKTOP_USER_ID, | |
| 44 | + | NewTask::builder(title).priority(Priority::Medium).build(), | |
| 45 | + | ) | |
| 46 | + | .unwrap() | |
| 47 | + | .id | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | fn get(state: &AppState, path: &str) -> Response { | |
| 51 | + | router() | |
| 52 | + | .handle(state, Request::get(path).carrying(Params::new())) | |
| 53 | + | .expect("the route answers") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fn html(response: Response) -> String { | |
| 57 | + | match response.outcome { | |
| 58 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), | |
| 59 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), | |
| 60 | + | Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), | |
| 61 | + | Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fn board(state: &AppState) -> String { | |
| 66 | + | html(get(state, "/board")) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn move_to(state: &AppState, id: goingson_core::TaskId, to: &str) -> Response { | |
| 70 | + | router() | |
| 71 | + | .handle( | |
| 72 | + | state, | |
| 73 | + | Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", to)), | |
| 74 | + | ) | |
| 75 | + | .expect("the route answers") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[tokio::test] | |
| 79 | + | async fn the_three_columns_are_peers_and_not_a_list_and_a_detail() { | |
| 80 | + | let state = state().await; | |
| 81 | + | let markup = board(&state); | |
| 82 | + | ||
| 83 | + | // The whole point of the member. Before 0.25.0 this screen could only have | |
| 84 | + | // been described as list-detail, which says the left column chooses what | |
| 85 | + | // the right shows -- a lie about a board. | |
| 86 | + | assert!(markup.contains("columns"), "{markup}"); | |
| 87 | + | assert!(!markup.contains("split"), "{markup}"); | |
| 88 | + | for label in ["Pending", "Started", "Completed"] { | |
| 89 | + | assert!(markup.contains(label), "{markup}"); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[tokio::test] | |
| 94 | + | async fn a_card_carries_the_facts_the_js_card_carried() { | |
| 95 | + | let state = state().await; | |
| 96 | + | task(&state, "Write the thing"); | |
| 97 | + | ||
| 98 | + | let markup = board(&state); | |
| 99 | + | ||
| 100 | + | assert!(markup.contains("Write the thing"), "{markup}"); | |
| 101 | + | assert!(markup.contains("Medium"), "{markup}"); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[tokio::test] | |
| 105 | + | async fn a_card_offers_the_moves_it_is_not_already_in() { | |
| 106 | + | let state = state().await; | |
| 107 | + | let id = task(&state, "Movable"); | |
| 108 | + | ||
| 109 | + | let markup = board(&state); | |
| 110 | + | ||
| 111 | + | // Two moves, not three. Dropping a card where it already is is the case | |
| 112 | + | // `onDrop` bails on, so offering it would be an act that does nothing. | |
| 113 | + | assert!(markup.contains("Move to Started"), "{markup}"); | |
| 114 | + | assert!(markup.contains("Move to Completed"), "{markup}"); | |
| 115 | + | assert!(!markup.contains("Move to Pending"), "{markup}"); | |
| 116 | + | ||
| 117 | + | // And the move is an ordinary posted action, which is why the drag never | |
| 118 | + | // needed describing. | |
| 119 | + | assert!(markup.contains(&format!("/board/{id}/status")), "{markup}"); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[tokio::test] | |
| 123 | + | async fn moving_a_card_moves_it_and_answers_the_board() { | |
| 124 | + | let state = state().await; | |
| 125 | + | let id = task(&state, "Movable"); | |
| 126 | + | ||
| 127 | + | let response = move_to(&state, id, "Started"); | |
| 128 | + | let Outcome::Fragment { region, .. } = &response.outcome else { | |
| 129 | + | panic!("a move replaces the board, not the screen"); | |
| 130 | + | }; | |
| 131 | + | assert_eq!(region, "board"); | |
| 132 | + | ||
| 133 | + | let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); | |
| 134 | + | assert_eq!(after.status, TaskStatus::Started); | |
| 135 | + | ||
| 136 | + | // Now it offers the way back and no longer offers the way it came. | |
| 137 | + | let markup = html(response); | |
| 138 | + | assert!(markup.contains("Move to Pending"), "{markup}"); | |
| 139 | + | assert!(!markup.contains("Move to Started"), "{markup}"); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[tokio::test] | |
| 143 | + | async fn moving_a_card_to_the_column_it_is_in_writes_nothing() { | |
| 144 | + | let state = state().await; | |
| 145 | + | let id = task(&state, "Stationary"); | |
| 146 | + | ||
| 147 | + | // Matters more through a route than through a drop. A repeated POST must | |
| 148 | + | // not complete a task twice and mint a second recurrence, and the JS's | |
| 149 | + | // `task.status === newStatus` bail is not reachable from a URL. | |
| 150 | + | let response = move_to(&state, id, "Pending"); | |
| 151 | + | assert!(matches!(response.outcome, Outcome::Fragment { .. })); | |
| 152 | + | ||
| 153 | + | let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); | |
| 154 | + | assert_eq!(after.status, TaskStatus::Pending); | |
| 155 | + | // No toast, because nothing happened. | |
| 156 | + | assert!(response.notice.is_none(), "{:?}", response.notice); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[tokio::test] | |
| 160 | + | async fn moving_back_to_pending_keeps_everything_else_about_the_task() { | |
| 161 | + | let state = state().await; | |
| 162 | + | let id = state | |
| 163 | + | .tasks | |
| 164 | + | .create( | |
| 165 | + | DESKTOP_USER_ID, | |
| 166 | + | NewTask::builder("Tagged") | |
| 167 | + | .priority(Priority::High) | |
| 168 | + | .tags(vec!["alpha".into(), "beta".into()]) | |
| 169 | + | .build(), | |
| 170 | + | ) | |
| 171 | + | .unwrap() | |
| 172 | + | .id; | |
| 173 | + | move_to(&state, id, "Started"); | |
| 174 | + | ||
| 175 | + | move_to(&state, id, "Pending"); | |
| 176 | + | ||
| 177 | + | // `UpdateTask` replaces rather than patches, so the Pending path resends | |
| 178 | + | // every field. Anything left out of that struct is silently cleared, and | |
| 179 | + | // moving a card left is not a reason to lose its tags. | |
| 180 | + | let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); | |
| 181 | + | assert_eq!(after.status, TaskStatus::Pending); | |
| 182 | + | assert_eq!(after.priority, Priority::High); | |
| 183 | + | assert_eq!(after.tags, vec!["alpha".to_string(), "beta".to_string()]); | |
| 184 | + | assert_eq!(after.title, "Tagged"); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[tokio::test] | |
| 188 | + | async fn a_column_that_does_not_exist_is_a_404() { | |
| 189 | + | let state = state().await; | |
| 190 | + | let id = task(&state, "Movable"); | |
| 191 | + | ||
| 192 | + | // A control naming a column that is not there is a wiring mistake, and | |
| 193 | + | // answering it with an unchanged board hides it. | |
| 194 | + | let answered = router().handle( | |
| 195 | + | &state, | |
| 196 | + | Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", "Archived")), | |
| 197 | + | ); | |
| 198 | + | assert!(answered.is_err(), "an unknown column should not resolve"); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[tokio::test] | |
| 202 | + | async fn a_cards_title_cannot_become_markup() { | |
| 203 | + | let state = state().await; | |
| 204 | + | task(&state, "<script>alert('x')</script>"); | |
| 205 | + | ||
| 206 | + | let markup = board(&state); | |
| 207 | + | ||
| 208 | + | assert!(!markup.contains("<script>"), "{markup}"); | |
| 209 | + | assert!(markup.contains("<script>"), "{markup}"); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tokio::test] | |
| 213 | + | async fn an_empty_column_says_so_rather_than_drawing_nothing() { | |
| 214 | + | let state = state().await; | |
| 215 | + | let markup = board(&state); | |
| 216 | + | ||
| 217 | + | // Three empty columns on a fresh board. An empty column that draws nothing | |
| 218 | + | // reads as a broken board rather than an empty one. | |
| 219 | + | assert_eq!(markup.matches("No tasks").count(), 3, "{markup}"); | |
| 220 | + | } |