max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+1124 insertions,
-76 deletions
| @@ -671,6 +671,17 @@ | |||
| 671 | 671 | /// | |
| 672 | 672 | /// For recurring tasks, this creates the next instance with an updated due date. | |
| 673 | 673 | /// | |
| 674 | + | /// The command below is a wrapper. The work is here so the described screens | |
| 675 | + | /// (`quasi::move_to`, and the drawer through it) complete the same way rather | |
| 676 | + | /// than calling the repository's `complete` and getting three quarters of it. | |
| 677 | + | /// The missing quarter is the interesting part, and none of it is visible from | |
| 678 | + | /// the repository method's name: a recurring task's successor is built *by the | |
| 679 | + | /// caller* and inserted in the same transaction, the running timer stops, and a | |
| 680 | + | /// milestone whose last task this was completes with it. `complete` alone does | |
| 681 | + | /// the status transition and nothing else, so a described board completing a | |
| 682 | + | /// weekly task ended its chain — silently, since the row simply left the list | |
| 683 | + | /// either way. | |
| 684 | + | /// | |
| 674 | 685 | /// # Returns | |
| 675 | 686 | /// | |
| 676 | 687 | /// - `completed`: Whether the task was marked complete | |
| @@ -679,12 +690,7 @@ | |||
| 679 | 690 | /// # Errors | |
| 680 | 691 | /// | |
| 681 | 692 | /// Returns `DATABASE_ERROR` if the update or insert fails. | |
| 682 | - | #[tauri::command] | |
| 683 | - | #[instrument(skip_all)] | |
| 684 | - | pub async fn complete_task( | |
| 685 | - | state: State<'_, Arc<AppState>>, | |
| 686 | - | id: TaskId, | |
| 687 | - | ) -> Result<CompleteTaskResponse, ApiError> { | |
| 693 | + | pub fn complete(state: &AppState, id: TaskId) -> Result<CompleteTaskResponse, ApiError> { | |
| 688 | 694 | // Auto-stop any running timer before completing | |
| 689 | 695 | let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID); | |
| 690 | 696 | ||
| @@ -735,6 +741,16 @@ | |||
| 735 | 741 | }) | |
| 736 | 742 | } | |
| 737 | 743 | ||
| 744 | + | /// Marks a task as completed. See [`complete`]. | |
| 745 | + | #[tauri::command] | |
| 746 | + | #[instrument(skip_all)] | |
| 747 | + | pub async fn complete_task( | |
| 748 | + | state: State<'_, Arc<AppState>>, | |
| 749 | + | id: TaskId, | |
| 750 | + | ) -> Result<CompleteTaskResponse, ApiError> { | |
| 751 | + | complete(&state, id) | |
| 752 | + | } | |
| 753 | + | ||
| 738 | 754 | // Task Overview | |
| 739 | 755 | ||
| 740 | 756 | /// A completed instance in a recurrence chain (lightweight). |
| @@ -45,7 +45,7 @@ | |||
| 45 | 45 | // plain `fn(&S, Request)` pointer, so the signature is the router's. | |
| 46 | 46 | #![allow(clippy::needless_pass_by_value)] | |
| 47 | 47 | ||
| 48 | - | use goingson_core::{Priority, Task, TaskId, TaskStatus, UpdateTask}; | |
| 48 | + | use goingson_core::{Priority, Task, TaskId, TaskStatus}; | |
| 49 | 49 | use makeover_layout::{Heading, Tone}; | |
| 50 | 50 | use quasi_router::screen::{Act, Meter, Row, Tag}; | |
| 51 | 51 | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| @@ -249,62 +249,9 @@ | |||
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | 251 | // Three different writes, which is what the JS does and is not incidental. | |
| 252 | - | // Starting stamps a start time and completing runs the recurrence rule, so | |
| 253 | - | // neither is "set the status column". | |
| 254 | - | let message = match to { | |
| 255 | - | TaskStatus::Started => { | |
| 256 | - | state | |
| 257 | - | .tasks | |
| 258 | - | .start(id, DESKTOP_USER_ID) | |
| 259 | - | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 260 | - | "Task started." | |
| 261 | - | } | |
| 262 | - | TaskStatus::Completed => { | |
| 263 | - | // The timer stops with it, matching `complete_task`. A card | |
| 264 | - | // completed with a timer running would otherwise keep accruing. | |
| 265 | - | let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID); | |
| 266 | - | state | |
| 267 | - | .tasks | |
| 268 | - | .complete(id, DESKTOP_USER_ID) | |
| 269 | - | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 270 | - | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 271 | - | "Task completed." | |
| 272 | - | } | |
| 273 | - | TaskStatus::Pending => { | |
| 274 | - | // `UpdateTask` replaces rather than patches, which is why the JS | |
| 275 | - | // resends every field on this path and why this does too. Built | |
| 276 | - | // from the task just read, so the only thing that changes is the | |
| 277 | - | // status; anything left out would be cleared, and moving a card | |
| 278 | - | // left is not a reason to lose its tags. | |
| 279 | - | state | |
| 280 | - | .tasks | |
| 281 | - | .update( | |
| 282 | - | id, | |
| 283 | - | DESKTOP_USER_ID, | |
| 284 | - | UpdateTask { | |
| 285 | - | project_id: task.project_id, | |
| 286 | - | milestone_id: task.milestone_id, | |
| 287 | - | contact_id: task.contact_id, | |
| 288 | - | title: task.title.clone(), | |
| 289 | - | description: task.description.clone(), | |
| 290 | - | status: TaskStatus::Pending, | |
| 291 | - | priority: task.priority, | |
| 292 | - | due: task.due, | |
| 293 | - | tags: task.tags.clone(), | |
| 294 | - | recurrence: task.recurrence, | |
| 295 | - | recurrence_rule: task.recurrence_rule.clone(), | |
| 296 | - | urgency: task.urgency, | |
| 297 | - | scheduled_start: task.scheduled_start, | |
| 298 | - | scheduled_duration: task.scheduled_duration, | |
| 299 | - | estimated_minutes: task.estimated_minutes, | |
| 300 | - | }, | |
| 301 | - | ) | |
| 302 | - | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 303 | - | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 304 | - | "Task moved to Pending." | |
| 305 | - | } | |
| 306 | - | TaskStatus::Deleted => return Err(RouteError::not_found("no such column")), | |
| 307 | - | }; | |
| 252 | + | // The task list offers the same three from a row, so they live in | |
| 253 | + | // [`super::move_to`] rather than here. | |
| 254 | + | let message = super::move_to(state, &task, &to)?; | |
| 308 | 255 | ||
| 309 | 256 | Ok( | |
| 310 | 257 | Response::fragment("board", Node::Region(board_region(state)?)) |
| @@ -41,7 +41,7 @@ | |||
| 41 | 41 | //! exist and both are counted. The count starts falling at the flip. Progress is | |
| 42 | 42 | //! the first list, not the number. | |
| 43 | 43 | //! | |
| 44 | - | //! ## Described, and retires at the flip (22 files, 193 sites) | |
| 44 | + | //! ## Described, and retires at the flip (27 files, 207 sites) | |
| 45 | 45 | //! | |
| 46 | 46 | //! | Module | JS counterpart | Sites | | |
| 47 | 47 | //! |---|---|---| | |
| @@ -55,9 +55,10 @@ | |||
| 55 | 55 | //! | [`monthly_review`] | `monthly-review.js` 3, `monthly-review-render.js` 5 | 8 | | |
| 56 | 56 | //! | [`settings`] | `settings.js` | 6 | | |
| 57 | 57 | //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 | | |
| 58 | + | //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 | | |
| 58 | 59 | //! | |
| 59 | - | //! [`projects`] carries its dashboard as a submodule, which is the eleventh | |
| 60 | - | //! described screen against ten modules here. | |
| 60 | + | //! [`projects`] carries its dashboard as a submodule, which is the twelfth | |
| 61 | + | //! described screen against eleven modules here. | |
| 61 | 62 | //! | |
| 62 | 63 | //! ## Stays JavaScript, by decision (4 files, 50 sites) | |
| 63 | 64 | //! | |
| @@ -69,15 +70,16 @@ | |||
| 69 | 70 | //! These 50 never reach zero by porting. Retiring `escape.js` means giving them | |
| 70 | 71 | //! typed escaping some other way, or accepting that four files keep an escaper. | |
| 71 | 72 | //! | |
| 72 | - | //! ## Un-ported screens (12 files, 57 sites) | |
| 73 | + | //! ## Un-ported screens (7 files, 43 sites) | |
| 73 | 74 | //! | |
| 74 | 75 | //! A described counterpart could exist and does not. This is the candidate list, | |
| 75 | 76 | //! and it is the only place to look for what is portable next. | |
| 76 | 77 | //! | |
| 77 | - | //! - The task **list**, which is not [`tasks`]: that module is the single-task | |
| 78 | - | //! drawer at `GET /tasks/{id}`, and [`board`] is the kanban. The list itself | |
| 79 | - | //! has no counterpart. `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, | |
| 80 | - | //! `task-forms.js` 1, `saved-views.js` 1. 14 sites. | |
| 78 | + | //! The task list left this list on 2026-08-15 and is [`task_list`] now. It was | |
| 79 | + | //! the largest candidate here and the one this table had to name twice, because | |
| 80 | + | //! it is neither [`tasks`] (the single-task drawer at `GET /tasks/{id}`) nor | |
| 81 | + | //! [`board`] (the kanban). | |
| 82 | + | //! | |
| 81 | 83 | //! - `import-external.js` 11, `import.js` 5, `export.js` 2. 18 sites. | |
| 82 | 84 | //! - `events.js` 10. Its grid rendering is bespoke; whether the CRUD/list half | |
| 83 | 85 | //! is a real candidate is unchecked, and per this table that is a claim to | |
| @@ -132,12 +134,12 @@ | |||
| 132 | 134 | ||
| 133 | 135 | use std::sync::Arc; | |
| 134 | 136 | ||
| 135 | - | use goingson_core::Task; | |
| 137 | + | use goingson_core::{Task, TaskStatus, UpdateTask}; | |
| 136 | 138 | use makeover_layout::Tone; | |
| 137 | - | use quasi_router::Router; | |
| 138 | 139 | use quasi_router::screen::Tag; | |
| 140 | + | use quasi_router::{RouteError, Router}; | |
| 139 | 141 | ||
| 140 | - | use crate::state::AppState; | |
| 142 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 141 | 143 | ||
| 142 | 144 | pub mod board; | |
| 143 | 145 | pub mod contacts; | |
| @@ -147,6 +149,7 @@ | |||
| 147 | 149 | pub mod problems; | |
| 148 | 150 | pub mod projects; | |
| 149 | 151 | pub mod settings; | |
| 152 | + | pub mod task_list; | |
| 150 | 153 | pub mod tasks; | |
| 151 | 154 | pub mod weekly_review; | |
| 152 | 155 | ||
| @@ -234,6 +237,83 @@ | |||
| 234 | 237 | } | |
| 235 | 238 | } | |
| 236 | 239 | ||
| 240 | + | /// Move a task to a named status, whoever asked. | |
| 241 | + | /// | |
| 242 | + | /// Two surfaces ask: the board, where it is a drop, and the task list, where it | |
| 243 | + | /// is a row's own control. It lives here for the reason [`Availability`] does. | |
| 244 | + | /// "Set the status column" is three different writes — starting stamps a start | |
| 245 | + | /// time, completing runs the recurrence rule and stops the timer, and going | |
| 246 | + | /// back to Pending resends every field because [`UpdateTask`] replaces rather | |
| 247 | + | /// than patches — and a second copy of that knowledge is a second answer to | |
| 248 | + | /// what completing a task means. | |
| 249 | + | /// | |
| 250 | + | /// Answers the sentence to say afterwards. The caller decides what to re-read, | |
| 251 | + | /// because only the caller knows which region it is answering. | |
| 252 | + | /// | |
| 253 | + | /// `to` is never [`TaskStatus::Deleted`]: deleting is its own route on both | |
| 254 | + | /// surfaces, and a status control that could delete would put it one keystroke | |
| 255 | + | /// from Completed. | |
| 256 | + | pub(crate) fn move_to( | |
| 257 | + | state: &AppState, | |
| 258 | + | task: &Task, | |
| 259 | + | to: &TaskStatus, | |
| 260 | + | ) -> Result<&'static str, RouteError> { | |
| 261 | + | match to { | |
| 262 | + | TaskStatus::Started => { | |
| 263 | + | state | |
| 264 | + | .tasks | |
| 265 | + | .start(task.id, DESKTOP_USER_ID) | |
| 266 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 267 | + | Ok("Task started.") | |
| 268 | + | } | |
| 269 | + | TaskStatus::Completed => { | |
| 270 | + | // [`crate::commands::complete`] and not the repository's `complete`, | |
| 271 | + | // which is a status transition and three quarters of what | |
| 272 | + | // completing a task means here. It was the repository's until | |
| 273 | + | // 2026-08-15, and the missing quarter never showed: a weekly task | |
| 274 | + | // completed from the board had its recurrence chain end there, a | |
| 275 | + | // running timer kept accruing, and a milestone whose last task it | |
| 276 | + | // was stayed open. The row left the list either way, which is why | |
| 277 | + | // nothing noticed. | |
| 278 | + | crate::commands::complete(state, task.id) | |
| 279 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 280 | + | Ok("Task completed.") | |
| 281 | + | } | |
| 282 | + | TaskStatus::Pending => { | |
| 283 | + | // Built from the task just read, so the only thing that changes is | |
| 284 | + | // the status; anything left out would be cleared, and moving a task | |
| 285 | + | // back to Pending is not a reason to lose its tags. | |
| 286 | + | state | |
| 287 | + | .tasks | |
| 288 | + | .update( | |
| 289 | + | task.id, | |
| 290 | + | DESKTOP_USER_ID, | |
| 291 | + | UpdateTask { | |
| 292 | + | project_id: task.project_id, | |
| 293 | + | milestone_id: task.milestone_id, | |
| 294 | + | contact_id: task.contact_id, | |
| 295 | + | title: task.title.clone(), | |
| 296 | + | description: task.description.clone(), | |
| 297 | + | status: TaskStatus::Pending, | |
| 298 | + | priority: task.priority.clone(), | |
| 299 | + | due: task.due, | |
| 300 | + | tags: task.tags.clone(), | |
| 301 | + | recurrence: task.recurrence.clone(), | |
| 302 | + | recurrence_rule: task.recurrence_rule.clone(), | |
| 303 | + | urgency: task.urgency, | |
| 304 | + | scheduled_start: task.scheduled_start, | |
| 305 | + | scheduled_duration: task.scheduled_duration, | |
| 306 | + | estimated_minutes: task.estimated_minutes, | |
| 307 | + | }, | |
| 308 | + | ) | |
| 309 | + | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 310 | + | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 311 | + | Ok("Task moved to Pending.") | |
| 312 | + | } | |
| 313 | + | TaskStatus::Deleted => Err(RouteError::not_found("not a status a control can set")), | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 237 | 317 | /// Every described screen's routes. | |
| 238 | 318 | #[must_use] | |
| 239 | 319 | pub fn router() -> Router<AppState> { | |
| @@ -247,6 +327,7 @@ | |||
| 247 | 327 | let router = problems::routes(router); | |
| 248 | 328 | let router = day_planning::routes(router); | |
| 249 | 329 | let router = board::routes(router); | |
| 330 | + | let router = task_list::routes(router); | |
| 250 | 331 | emails::routes(router) | |
| 251 | 332 | } | |
| 252 | 333 |
| @@ -784,11 +784,15 @@ | |||
| 784 | 784 | /// JS does: it re-opens the task it was showing. | |
| 785 | 785 | fn complete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 786 | 786 | let id = task_id(&request)?; | |
| 787 | - | state | |
| 787 | + | // Through [`super::move_to`], so this drawer, the board and the task list | |
| 788 | + | // are one answer to what completing a task means. See the note there for | |
| 789 | + | // what the repository's `complete` leaves out. | |
| 790 | + | let task = state | |
| 788 | 791 | .tasks | |
| 789 | - | .complete(id, DESKTOP_USER_ID) | |
| 792 | + | .get_by_id(id, DESKTOP_USER_ID) | |
| 790 | 793 | .map_err(|error| RouteError::internal(error.to_string()))? | |
| 791 | 794 | .ok_or_else(|| RouteError::not_found("no such task"))?; | |
| 795 | + | super::move_to(state, &task, &TaskStatus::Completed)?; | |
| 792 | 796 | wrote(state, id) | |
| 793 | 797 | } | |
| 794 | 798 |
| @@ -1,0 +1,988 @@ | |||
| 1 | + | //! The task list, described rather than built. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! Twelfth port, and the largest un-ported screen by weight: `tasks.js` 948, | |
| 6 | + | //! `tasks-render.js` 406, `tasks-filter.js` 263, `task-forms.js` 499 and | |
| 7 | + | //! `saved-views.js` 165, carrying 14 `esc()` sites between them. It is also the | |
| 8 | + | //! screen [the module above](super) had to name twice to stop people reaching | |
| 9 | + | //! for the wrong module: [`tasks`](super::tasks) is the single-task drawer at | |
| 10 | + | //! `GET /tasks/{id}` and [`board`](super::board) is the kanban. This is the | |
| 11 | + | //! list. | |
| 12 | + | //! | |
| 13 | + | //! # It closes a dangling redirect | |
| 14 | + | //! | |
| 15 | + | //! [`super::tasks`]'s delete has answered `Response::goto(Action::get("/tasks"))` | |
| 16 | + | //! since 2026-08-09, and until this module there was no `GET /tasks`. Deleting | |
| 17 | + | //! from the drawer redirected to a route nothing served, and a test asserted the | |
| 18 | + | //! destination rather than that anything answered it. That is what a redirect | |
| 19 | + | //! costs over a fragment: the address is checked when it is followed, and | |
| 20 | + | //! nothing followed it. | |
| 21 | + | //! | |
| 22 | + | //! # The shape | |
| 23 | + | //! | |
| 24 | + | //! - `GET /tasks` — the whole screen. | |
| 25 | + | //! - `GET /tasks/list` — the table alone, which is what a filter, a sort, or | |
| 26 | + | //! "show more" swaps. | |
| 27 | + | //! - `POST /tasks/list/{id}/status` — start it, complete it, or send it back to | |
| 28 | + | //! Pending, carrying `status`. | |
| 29 | + | //! - `POST /tasks/list/{id}/delete` — delete it. | |
| 30 | + | //! | |
| 31 | + | //! The writes are under `/tasks/list/` rather than beside the drawer's own | |
| 32 | + | //! `POST /tasks/{id}/complete`, and that is the honest name rather than a | |
| 33 | + | //! workaround: a write is answered by the region it happened in, the drawer | |
| 34 | + | //! answers with the drawer, and one route cannot answer both. The three status | |
| 35 | + | //! writes themselves are [`super::move_to`], shared with the board, because | |
| 36 | + | //! "set the status column" is three different writes and two copies of that is | |
| 37 | + | //! two answers to what completing a task means. | |
| 38 | + | //! | |
| 39 | + | //! # The first table | |
| 40 | + | //! | |
| 41 | + | //! `Node::Table`, `Column::reorder` and `Cells` have been in the vocabulary | |
| 42 | + | //! since `ce620871` and no port had used one: the eleven before this were lists, | |
| 43 | + | //! cards and a timeline. The shipped screen is a real table — seven columns, | |
| 44 | + | //! four of them sortable, and a build script that already describes them — so | |
| 45 | + | //! this is where the members meet their first consumer, and three of the four | |
| 46 | + | //! findings below come from that. | |
| 47 | + | //! | |
| 48 | + | //! # Findings | |
| 49 | + | //! | |
| 50 | + | //! **1. The column description exists twice, in two vocabularies.** `build.rs` | |
| 51 | + | //! holds `TASK_COLUMNS` as `makeover_layout::Column` and emits the narrowing CSS | |
| 52 | + | //! and the JSON the frontend tests check themselves against; [`COLUMNS`] here | |
| 53 | + | //! holds the same seven columns as `quasi_router::screen::Column` because the | |
| 54 | + | //! runtime one carries an address and the build-time one cannot. The two agree | |
| 55 | + | //! today and nothing checks that they do. Not a vocabulary gap — the overlap is | |
| 56 | + | //! name, width and priority, which a build script could emit as a Rust const for | |
| 57 | + | //! this module to take the address half onto. Left alone because inventing that | |
| 58 | + | //! generator while porting the screen is two changes wearing one commit. | |
| 59 | + | //! | |
| 60 | + | //! **2. A table cannot say there is more.** [`Node::List`] carries a | |
| 61 | + | //! [`Rest`](quasi_router::screen::Rest) and `Node::Table` carries nothing of the | |
| 62 | + | //! kind, so paging is a [`Node::Act`] sitting under the table rather than a | |
| 63 | + | //! property of it. The shipped screen streams pages into a virtual scroller as | |
| 64 | + | //! you scroll; a description has no word for that either, and the address-shaped | |
| 65 | + | //! answer the mail list already settled on — the view says how many rows it | |
| 66 | + | //! shows, and asking for more is an address — is what this uses. What is lost is | |
| 67 | + | //! the renderer knowing the act belongs to the table above it. | |
| 68 | + | //! | |
| 69 | + | //! **3. A table row cannot join a selection.** [`Row::ticking`](quasi_router::screen::Row::ticking) exists, | |
| 70 | + | //! `Screen::selecting` and `Act::over` exist, and `Cells` deliberately has | |
| 71 | + | //! neither: its doc says "no table asks for one, and a member added because its | |
| 72 | + | //! sibling has it is a member with no consumer to tell us what it should mean." | |
| 73 | + | //! One asks now. The shipped list has a per-row checkbox, shift-range selection | |
| 74 | + | //! and a bulk-actions bar, and none of it is sayable here, so this port has no | |
| 75 | + | //! bulk selection at all rather than a worse version of it. | |
| 76 | + | //! | |
| 77 | + | //! **4. Saved views are a screen this one does not have.** `saved-views.js` | |
| 78 | + | //! stores named filter sets. Under this port a view *is* an address, which is | |
| 79 | + | //! most of what saving one was for, but naming and listing them is a store and a | |
| 80 | + | //! screen of its own and is not part of describing this one. | |
| 81 | + | //! | |
| 82 | + | //! # What the row offers, and what it does not | |
| 83 | + | //! | |
| 84 | + | //! Start, Complete, Delete, and the title opens the drawer. The shipped row's | |
| 85 | + | //! kebab also offers Edit, Manage Subtasks, Add Note, Attachments, Snooze, | |
| 86 | + | //! Schedule, Track Time and Focus. Every one of those opens a modal over the | |
| 87 | + | //! list, and a modal form over a screen is a second arrangement this screen | |
| 88 | + | //! would have to describe before it could offer it — [`super::tasks`] recorded | |
| 89 | + | //! that refusal for Edit and it is unchanged. What they are not is lost: they | |
| 90 | + | //! all live on the drawer the title opens. | |
| 91 | + | //! | |
| 92 | + | //! Delete asks for confirmation, where the shipped screen deliberately does not: | |
| 93 | + | //! `tasks.js` argues, correctly, that its delete is optimistic with the API call | |
| 94 | + | //! deferred until an undo window closes, so the undo toast *is* the recovery and | |
| 95 | + | //! a "cannot be undone" modal would be false. A description has no word for an | |
| 96 | + | //! undo window. Under this port the deletion is immediate, so it is confirmed, | |
| 97 | + | //! which is the same standard the drawer and the projects screen already hold. | |
| 98 | + | //! | |
| 99 | + | //! # The default order is the screen's, not the command's | |
| 100 | + | //! | |
| 101 | + | //! `list_tasks_filtered` defaults to urgency descending; `tasks-filter.js` opens | |
| 102 | + | //! on `due` ascending and has since it was written. This is a port of the | |
| 103 | + | //! screen, so the screen's default is the one kept. Urgency is therefore a sort | |
| 104 | + | //! this list cannot reach, exactly as on the shipped screen: it is a column in | |
| 105 | + | //! [`TaskSortColumn`] with no heading to press, and `sortTasks`'s "descending if | |
| 106 | + | //! urgency" branch has never run. | |
| 107 | + | ||
| 108 | + | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 109 | + | // plain `fn(&S, Request)` pointer, so the signature is the router's and not a | |
| 110 | + | // choice made here. Same allow, for the same reason, as quasi-axum's tests. | |
| 111 | + | #![allow(clippy::needless_pass_by_value)] | |
| 112 | + | ||
| 113 | + | use goingson_core::{ | |
| 114 | + | MilestoneId, Priority, ProjectId, SortDirection, Task, TaskFilterQuery, TaskId, TaskSortColumn, | |
| 115 | + | TaskStatus, | |
| 116 | + | }; | |
| 117 | + | use makeover_layout::{Sort, Tone, Width}; | |
| 118 | + | use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Tag}; | |
| 119 | + | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 120 | + | ||
| 121 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 122 | + | ||
| 123 | + | #[cfg(test)] | |
| 124 | + | mod tests; | |
| 125 | + | ||
| 126 | + | /// How many rows a page is. | |
| 127 | + | /// | |
| 128 | + | /// `TASK_PAGE_SIZE` in `tasks.js`, and the same number for the same reason: it | |
| 129 | + | /// is what one scroll's worth of streaming asks for. | |
| 130 | + | const PAGE: i64 = 200; | |
| 131 | + | ||
| 132 | + | /// The ceiling on `shown`. | |
| 133 | + | /// | |
| 134 | + | /// Ten pages, which is where the shipped screen's own paging would arrive after | |
| 135 | + | /// ten scrolls, and what stops a hand-typed address asking for a million rows. | |
| 136 | + | const PAGES: i64 = 10; | |
| 137 | + | ||
| 138 | + | /// The table, left to right. | |
| 139 | + | /// | |
| 140 | + | /// The same seven columns as `build.rs`'s `TASK_COLUMNS`, in the same order, | |
| 141 | + | /// with the same widths and the same four marked sortable — see finding 1 above | |
| 142 | + | /// for why the description exists in two places and what would close it. The | |
| 143 | + | /// priority a column has when room runs out is the build script's business, | |
| 144 | + | /// because it is what generates the narrowing CSS, so only the width is | |
| 145 | + | /// restated here; a renderer with no stylesheet reads it off the same order. | |
| 146 | + | const COLUMNS: [(&str, Width, Option<TaskSortColumn>); 7] = [ | |
| 147 | + | ( | |
| 148 | + | "description", | |
| 149 | + | Width::Fill, | |
| 150 | + | Some(TaskSortColumn::Description), | |
| 151 | + | ), | |
| 152 | + | ("project", Width::Fixed, Some(TaskSortColumn::Project)), | |
| 153 | + | ("priority", Width::Fixed, Some(TaskSortColumn::Priority)), | |
| 154 | + | ("due", Width::Fixed, Some(TaskSortColumn::Due)), | |
| 155 | + | ("recurrence", Width::Fixed, None), | |
| 156 | + | ("progress", Width::Fixed, None), | |
| 157 | + | ("actions", Width::Fixed, None), | |
| 158 | + | ]; | |
| 159 | + | ||
| 160 | + | /// The statuses the filter offers, in the order the control reads. | |
| 161 | + | /// | |
| 162 | + | /// `None` is "every status", which the shipped `<select>` spells as its blank | |
| 163 | + | /// option. `Deleted` is not offered: a deleted task is not on the list, and | |
| 164 | + | /// `list_filtered` does not return one. | |
| 165 | + | const STATUSES: [Option<TaskStatus>; 4] = [ | |
| 166 | + | Some(TaskStatus::Pending), | |
| 167 | + | Some(TaskStatus::Started), | |
| 168 | + | Some(TaskStatus::Completed), | |
| 169 | + | None, | |
| 170 | + | ]; | |
| 171 | + | ||
| 172 | + | /// The priorities the filter offers. | |
| 173 | + | const PRIORITIES: [Priority; 3] = [Priority::High, Priority::Medium, Priority::Low]; | |
| 174 | + | ||
| 175 | + | /// The word a priority travels and reads as. | |
| 176 | + | /// | |
| 177 | + | /// **Not `Priority::as_str`, which is the single letter `H`/`M`/`L`.** That | |
| 178 | + | /// method is a display abbreviation for the priority *column*, where the cell is | |
| 179 | + | /// one character wide; `TaskStatus::as_str` next to it is the stored word. Two | |
| 180 | + | /// methods, one name, two different kinds of answer — and the filter chips were | |
| 181 | + | /// built with the wrong one, so every priority chip on this screen offered | |
| 182 | + | /// `?priority=H`, which [`View::of`] answers with a 404. Caught by the test that | |
| 183 | + | /// presses one. | |
| 184 | + | const fn priority_word(priority: &Priority) -> &'static str { | |
| 185 | + | match priority { | |
| 186 | + | Priority::High => "High", | |
| 187 | + | Priority::Medium => "Medium", | |
| 188 | + | Priority::Low => "Low", | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// The tone a priority wears. [`super::tasks`]'s `priority_tone`, which is the | |
| 193 | + | /// same mapping and is not re-derived here on purpose. | |
| 194 | + | const fn priority_tone(priority: &Priority) -> Tone { | |
| 195 | + | match priority { | |
| 196 | + | Priority::High => Tone::Danger, | |
| 197 | + | Priority::Medium => Tone::Warning, | |
| 198 | + | Priority::Low => Tone::Neutral, | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// The word a sort column travels as. | |
| 203 | + | /// | |
| 204 | + | /// `TaskSortColumn::from_str_or_default` reads these and defaults silently, | |
| 205 | + | /// which is right for a command taking whatever a caller sent and wrong for an | |
| 206 | + | /// address; [`View::of`] parses strictly against the same words. | |
| 207 | + | const fn sort_word(column: TaskSortColumn) -> &'static str { | |
| 208 | + | match column { | |
| 209 | + | TaskSortColumn::Description => "description", | |
| 210 | + | TaskSortColumn::Project => "project", | |
| 211 | + | TaskSortColumn::Priority => "priority", | |
| 212 | + | TaskSortColumn::Due => "due", | |
| 213 | + | TaskSortColumn::Urgency => "urgency", | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// A param that is present and not blank. Blank is absent, which is what the | |
| 218 | + | /// "all projects" option means. | |
| 219 | + | fn text(params: &quasi_router::Params, name: &str) -> Option<String> { | |
| 220 | + | params | |
| 221 | + | .get(name) | |
| 222 | + | .map(str::trim) | |
| 223 | + | .filter(|value| !value.is_empty()) | |
| 224 | + | .map(str::to_owned) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// A uuid-shaped param, or a 404. | |
| 228 | + | /// | |
| 229 | + | /// An id that does not parse is a wiring mistake, and answering it with the | |
| 230 | + | /// unfiltered list hides one. | |
| 231 | + | fn id_param<T: From<uuid::Uuid>>( | |
| 232 | + | params: &quasi_router::Params, | |
| 233 | + | name: &str, | |
| 234 | + | ) -> Result<Option<T>, RouteError> { | |
| 235 | + | match text(params, name) { | |
| 236 | + | None => Ok(None), | |
| 237 | + | Some(raw) => uuid::Uuid::parse_str(&raw) | |
| 238 | + | .map(|id| Some(T::from(id))) | |
| 239 | + | .map_err(|_| RouteError::not_found("not an id")), | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// Which rows, in what order, and how many of them. | |
| 244 | + | /// | |
| 245 | + | /// Query params rather than module state, per decision 2. `tasks-filter.js` | |
| 246 | + | /// holds the sort in module scope and mirrors the filters into the query string | |
| 247 | + | /// by hand — `writeFiltersToUrl` and `restoreFiltersFromUrl`, two functions and | |
| 248 | + | /// six ids each — so the shipped screen already agreed that a view is an | |
| 249 | + | /// address, and paid for it twice. Here the address is the only copy. | |
| 250 | + | #[derive(Clone, PartialEq)] | |
| 251 | + | struct View { | |
| 252 | + | /// The status being looked at. `None` is every status. | |
| 253 | + | status: Option<TaskStatus>, | |
| 254 | + | /// The project being looked at, if it is one project. | |
| 255 | + | project: Option<ProjectId>, | |
| 256 | + | /// The milestone within that project, if it is one milestone. | |
| 257 | + | milestone: Option<MilestoneId>, | |
| 258 | + | /// The priority being looked at, if it is one priority. | |
| 259 | + | priority: Option<Priority>, | |
| 260 | + | /// Whether snoozed tasks are included. | |
| 261 | + | snoozed: bool, | |
| 262 | + | /// Whether the list is cut to what is waiting on somebody else. | |
| 263 | + | waiting: bool, | |
| 264 | + | /// What the table is ordered by. | |
| 265 | + | sort: TaskSortColumn, | |
| 266 | + | /// Which way. | |
| 267 | + | direction: SortDirection, | |
| 268 | + | /// How many rows are on screen. | |
| 269 | + | /// | |
| 270 | + | /// The mail list's answer, and its reasoning applies unchanged: the JS | |
| 271 | + | /// appends what it has fetched, an address cannot append, so this says how | |
| 272 | + | /// many rows the list shows and the query asks for that many from the top. | |
| 273 | + | /// The same rows arrive either way, and this address re-opens to what it | |
| 274 | + | /// described. | |
| 275 | + | shown: i64, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | impl Default for View { | |
| 279 | + | /// What `GET /tasks` with no params is: pending work, most urgent first by | |
| 280 | + | /// due date, one page of it. | |
| 281 | + | /// | |
| 282 | + | /// `filter-status` opens on `pending` and `currentSortColumn` on `due` | |
| 283 | + | /// ascending. Both are the shipped screen's, not the command's. | |
| 284 | + | fn default() -> Self { | |
| 285 | + | Self { | |
| 286 | + | status: Some(TaskStatus::Pending), | |
| 287 | + | project: None, | |
| 288 | + | milestone: None, | |
| 289 | + | priority: None, | |
| 290 | + | snoozed: false, | |
| 291 | + | waiting: false, | |
| 292 | + | sort: TaskSortColumn::Due, | |
| 293 | + | direction: SortDirection::Asc, | |
| 294 | + | shown: PAGE, | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | impl View { | |
| 300 | + | /// The view a route was addressed at. | |
| 301 | + | fn of(request: &quasi_router::Request) -> Result<Self, RouteError> { | |
| 302 | + | let status = match text(&request.carried, "status") { | |
| 303 | + | None => Some(TaskStatus::Pending), | |
| 304 | + | Some(word) if word.eq_ignore_ascii_case("all") => None, | |
| 305 | + | Some(word) => Some(match word.as_str() { | |
| 306 | + | "Pending" => TaskStatus::Pending, | |
| 307 | + | "Started" => TaskStatus::Started, | |
| 308 | + | "Completed" => TaskStatus::Completed, | |
| 309 | + | _ => return Err(RouteError::not_found("not a task status")), | |
| 310 | + | }), | |
| 311 | + | }; | |
| 312 | + | ||
| 313 | + | let priority = match text(&request.carried, "priority") { | |
| 314 | + | None => None, | |
| 315 | + | Some(word) => Some(match word.as_str() { | |
| 316 | + | "High" => Priority::High, | |
| 317 | + | "Medium" => Priority::Medium, | |
| 318 | + | "Low" => Priority::Low, | |
| 319 | + | _ => return Err(RouteError::not_found("not a priority")), | |
| 320 | + | }), | |
| 321 | + | }; | |
| 322 | + | ||
| 323 | + | let sort = match text(&request.carried, "sort") { | |
| 324 | + | None => TaskSortColumn::Due, | |
| 325 | + | Some(word) => match word.as_str() { | |
| 326 | + | "description" => TaskSortColumn::Description, | |
| 327 | + | "project" => TaskSortColumn::Project, | |
| 328 | + | "priority" => TaskSortColumn::Priority, | |
| 329 | + | "due" => TaskSortColumn::Due, | |
| 330 | + | "urgency" => TaskSortColumn::Urgency, | |
| 331 | + | _ => return Err(RouteError::not_found("not a sortable column")), | |
| 332 | + | }, | |
| 333 | + | }; | |
| 334 | + | ||
| 335 | + | Ok(Self { | |
| 336 | + | status, | |
| 337 | + | project: id_param(&request.carried, "project")?, | |
| 338 | + | milestone: id_param(&request.carried, "milestone")?, | |
| 339 | + | priority, | |
| 340 | + | snoozed: matches!(request.carried.get("snoozed"), Some("1" | "true")), | |
| 341 | + | waiting: matches!(request.carried.get("waiting"), Some("1" | "true")), | |
| 342 | + | sort, | |
| 343 | + | direction: match request.carried.get("direction") { | |
| 344 | + | Some("desc") => SortDirection::Desc, | |
| 345 | + | _ => SortDirection::Asc, | |
| 346 | + | }, | |
| 347 | + | // Clamped rather than refused, for the reason the mail list gives: | |
| 348 | + | // this is an address, and landing on the first page is a more | |
| 349 | + | // useful answer than an error page. | |
| 350 | + | shown: request | |
| 351 | + | .carried | |
| 352 | + | .get("shown") | |
| 353 | + | .and_then(|raw| raw.parse::<i64>().ok()) | |
| 354 | + | .unwrap_or(PAGE) | |
| 355 | + | .clamp(PAGE, PAGE * PAGES), | |
| 356 | + | }) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | /// The same action, still pointed at the view it was offered under. | |
| 360 | + | /// | |
| 361 | + | /// A default is never written, so two addresses for one view cannot exist. | |
| 362 | + | fn carry(&self, action: Action) -> Action { | |
| 363 | + | let mut action = action; | |
| 364 | + | match &self.status { | |
| 365 | + | Some(TaskStatus::Pending) => {} | |
| 366 | + | Some(status) => action = action.carrying("status", status.as_str()), | |
| 367 | + | None => action = action.carrying("status", "all"), | |
| 368 | + | } | |
| 369 | + | if let Some(project) = self.project { | |
| 370 | + | action = action.carrying("project", project.to_string()); | |
| 371 | + | } | |
| 372 | + | if let Some(milestone) = self.milestone { | |
| 373 | + | action = action.carrying("milestone", milestone.to_string()); | |
| 374 | + | } | |
| 375 | + | if let Some(priority) = &self.priority { | |
| 376 | + | action = action.carrying("priority", priority_word(priority)); | |
| 377 | + | } | |
| 378 | + | if self.snoozed { | |
| 379 | + | action = action.carrying("snoozed", "1"); | |
| 380 | + | } | |
| 381 | + | if self.waiting { | |
| 382 | + | action = action.carrying("waiting", "1"); | |
| 383 | + | } | |
| 384 | + | if self.sort != TaskSortColumn::Due { | |
| 385 | + | action = action.carrying("sort", sort_word(self.sort)); | |
| 386 | + | } | |
| 387 | + | if self.direction == SortDirection::Desc { | |
| 388 | + | action = action.carrying("direction", "desc"); | |
| 389 | + | } | |
| 390 | + | if self.shown != PAGE { | |
| 391 | + | action = action.carrying("shown", self.shown.to_string()); | |
| 392 | + | } | |
| 393 | + | action | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// The address of the table under this view. | |
| 397 | + | fn list(&self) -> Action { | |
| 398 | + | self.carry(Action::get("/tasks/list")) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | /// The same view showing one page. | |
| 402 | + | /// | |
| 403 | + | /// A filter change is a new set of rows, so `shown` goes back to one page. | |
| 404 | + | /// Carrying it would ask for 2000 rows of a project holding nine. | |
| 405 | + | fn first_page(&self) -> Self { | |
| 406 | + | Self { | |
| 407 | + | shown: PAGE, | |
| 408 | + | ..self.clone() | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// The view ordered by this column: flipped if it is already the sort, | |
| 413 | + | /// ascending if it is not. | |
| 414 | + | /// | |
| 415 | + | /// `sortTasks`, which also has an "descending if urgency" branch that has | |
| 416 | + | /// never run because urgency has no heading to press. | |
| 417 | + | fn sorted_by(&self, column: TaskSortColumn) -> Self { | |
| 418 | + | Self { | |
| 419 | + | sort: column, | |
| 420 | + | direction: if self.sort == column { | |
| 421 | + | match self.direction { | |
| 422 | + | SortDirection::Asc => SortDirection::Desc, | |
| 423 | + | SortDirection::Desc => SortDirection::Asc, | |
| 424 | + | } | |
| 425 | + | } else { | |
| 426 | + | SortDirection::Asc | |
| 427 | + | }, | |
| 428 | + | ..self.first_page() | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | /// What this view asks the repository for. | |
| 433 | + | fn query(&self) -> TaskFilterQuery { | |
| 434 | + | TaskFilterQuery { | |
| 435 | + | status: self.status.clone(), | |
| 436 | + | project_id: self.project, | |
| 437 | + | milestone_id: self.milestone, | |
| 438 | + | priority: self.priority.clone(), | |
| 439 | + | show_snoozed: self.snoozed, | |
| 440 | + | waiting_only: self.waiting, | |
| 441 | + | offset: Some(0), | |
| 442 | + | limit: Some(self.shown), | |
| 443 | + | sort_column: Some(self.sort), | |
| 444 | + | sort_direction: Some(self.direction), | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | /// Whether anything has been narrowed. What "Clear filters" is offered for, | |
| 449 | + | /// and it deliberately ignores the sort and the page: neither hides a row. | |
| 450 | + | fn filtered(&self) -> bool { | |
| 451 | + | let default = Self::default(); | |
| 452 | + | self.status != default.status | |
| 453 | + | || self.project.is_some() | |
| 454 | + | || self.milestone.is_some() | |
| 455 | + | || self.priority.is_some() | |
| 456 | + | || self.snoozed | |
| 457 | + | || self.waiting | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | /// Minutes, the way the row says them. `formatMinutes` in `tasks-render.js`. | |
| 462 | + | fn minutes(total: i32) -> String { | |
| 463 | + | if total >= 60 { | |
| 464 | + | format!("{}h {}m", total / 60, total % 60) | |
| 465 | + | } else { | |
| 466 | + | format!("{total}m") | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | /// What the row says about time spent on the task. | |
| 471 | + | /// | |
| 472 | + | /// The tracked-against-estimated badge, and whether a timer is running. What it | |
| 473 | + | /// does not say is *how long* the running timer has been running: the shipped | |
| 474 | + | /// row draws a live elapsed value ticked by one app-wide interval, and who owns | |
| 475 | + | /// a ticking clock is quasicoherent `f00244a6`, undecided. A description that | |
| 476 | + | /// said "18m" here would be describing the moment it was rendered. | |
| 477 | + | fn time_token(task: &Task) -> Option<Tag> { | |
| 478 | + | if task.has_active_timer() { | |
| 479 | + | return Some(Tag::badge("Timer running").tone(Tone::Info)); | |
| 480 | + | } | |
| 481 | + | let tracked = task.actual_minutes; | |
| 482 | + | match (tracked, task.estimated_minutes) { | |
| 483 | + | (0, None) => None, | |
| 484 | + | (_, Some(estimate)) => Some( | |
| 485 | + | Tag::badge(format!("{} / {}", minutes(tracked), minutes(estimate))).tone( | |
| 486 | + | if task.is_over_estimate() { | |
| 487 | + | Tone::Warning | |
| 488 | + | } else { | |
| 489 | + | Tone::Neutral | |
| 490 | + | }, | |
| 491 | + | ), | |
| 492 | + | ), | |
| 493 | + | (_, None) => Some(Tag::badge(minutes(tracked)).tone(Tone::Neutral)), | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | /// The description cell: what the task is, and everything true of it that has | |
| 498 | + | /// no column of its own. | |
| 499 | + | /// | |
| 500 | + | /// # Two facts the shipped cell says twice, said once here |
Lines truncated
| @@ -1,0 +1,591 @@ | |||
| 1 | + | //! The task list, driven through the router against a real database. | |
| 2 | + | //! | |
| 3 | + | //! Two groups are worth reading. The first is the table: this is the vocabulary's | |
| 4 | + | //! first `Node::Table`, so the assertions are about columns being columns and a | |
| 5 | + | //! heading carrying the address that reorders by it. The second is the view: a | |
| 6 | + | //! filter here is an address, where `tasks-filter.js` holds it in module scope | |
| 7 | + | //! and mirrors it into the query string by hand, so the tests state what a | |
| 8 | + | //! mistyped address answers rather than letting it fall back to a different list | |
| 9 | + | //! than the one asked for. | |
| 10 | + | ||
| 11 | + | use std::sync::Arc; | |
| 12 | + | ||
| 13 | + | use goingson_core::{ | |
| 14 | + | NewProject, NewTask, Priority, ProjectStatus, ProjectType, Recurrence, TaskId, TaskStatus, | |
| 15 | + | }; | |
| 16 | + | use quasi_http::Serves as _; | |
| 17 | + | use quasi_router::{Outcome, Params, Request, Response}; | |
| 18 | + | ||
| 19 | + | use super::super::router; | |
| 20 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 21 | + | ||
| 22 | + | async fn state() -> Arc<AppState> { | |
| 23 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 24 | + | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 25 | + | state | |
| 26 | + | .db | |
| 27 | + | .conn() | |
| 28 | + | .unwrap() | |
| 29 | + | .execute( | |
| 30 | + | "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ | |
| 31 | + | VALUES (?, ?, ?, ?, ?)", | |
| 32 | + | rusqlite::params![ | |
| 33 | + | DESKTOP_USER_ID.to_string(), | |
| 34 | + | "desktop@localhost", | |
| 35 | + | "x", | |
| 36 | + | "Desktop User", | |
| 37 | + | &now, | |
| 38 | + | ], | |
| 39 | + | ) | |
| 40 | + | .unwrap(); | |
| 41 | + | state | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | fn task(state: &AppState, title: &str) -> TaskId { | |
| 45 | + | state | |
| 46 | + | .tasks | |
| 47 | + | .create( | |
| 48 | + | DESKTOP_USER_ID, | |
| 49 | + | NewTask::builder(title).priority(Priority::Medium).build(), | |
| 50 | + | ) | |
| 51 | + | .unwrap() | |
| 52 | + | .id | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | fn get(state: &AppState, path: &str, params: Params) -> Response { | |
| 56 | + | router() | |
| 57 | + | .handle(state, Request::get(path).carrying(params)) | |
| 58 | + | .expect("the route answers") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | fn post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response { | |
| 62 | + | router() | |
| 63 | + | .handle( | |
| 64 | + | state, | |
| 65 | + | Request::post(path).sending(payload).carrying(carried), | |
| 66 | + | ) | |
| 67 | + | .expect("the route answers") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn html(response: Response) -> String { | |
| 71 | + | match response.outcome { | |
| 72 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), | |
| 73 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), | |
| 74 | + | Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), | |
| 75 | + | Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// The whole screen under the default view. | |
| 80 | + | fn screen(state: &AppState) -> String { | |
| 81 | + | html(get(state, "/tasks", Params::new())) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // The screen | |
| 85 | + | ||
| 86 | + | #[tokio::test] | |
| 87 | + | async fn the_drawers_delete_redirect_now_lands_somewhere() { | |
| 88 | + | let state = state().await; | |
| 89 | + | let id = task(&state, "Doomed"); | |
| 90 | + | ||
| 91 | + | // `super::super::tasks::remove` has answered `goto /tasks` since 2026-08-09 | |
| 92 | + | // and nothing served that route until this module. The old test asserted the | |
| 93 | + | // destination; this one follows it. | |
| 94 | + | let response = router() | |
| 95 | + | .handle(&state, Request::post(format!("/tasks/{id}/delete"))) | |
| 96 | + | .expect("the route answers"); | |
| 97 | + | let Outcome::Goto(action) = response.outcome else { | |
| 98 | + | panic!("deleting from the drawer redirects"); | |
| 99 | + | }; | |
| 100 | + | let route = action.destination.route().expect("a route to follow"); | |
| 101 | + | assert_eq!(route, "/tasks"); | |
| 102 | + | ||
| 103 | + | let followed = router().handle(&state, Request::get(route).carrying(Params::new())); | |
| 104 | + | assert!(followed.is_ok(), "the redirect's destination answers"); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[tokio::test] | |
| 108 | + | async fn the_table_is_the_columns_the_build_script_describes_in_that_order() { | |
| 109 | + | let state = state().await; | |
| 110 | + | task(&state, "Write the thing"); | |
| 111 | + | ||
| 112 | + | let markup = screen(&state); | |
| 113 | + | ||
| 114 | + | // The same seven, in the same order, as `build.rs`'s TASK_COLUMNS. Finding | |
| 115 | + | // 1 in the module header: the two descriptions agree and nothing but this | |
| 116 | + | // checks that they do. | |
| 117 | + | let mut at = 0; | |
| 118 | + | for name in [ | |
| 119 | + | "description", | |
| 120 | + | "project", | |
| 121 | + | "priority", | |
| 122 | + | "due", | |
| 123 | + | "recurrence", | |
| 124 | + | "progress", | |
| 125 | + | "actions", | |
| 126 | + | ] { | |
| 127 | + | let found = markup[at..] | |
| 128 | + | .find(name) | |
| 129 | + | .unwrap_or_else(|| panic!("no {name} column in {markup}")); | |
| 130 | + | at += found + name.len(); | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[tokio::test] | |
| 135 | + | async fn a_row_carries_the_facts_the_js_row_carried() { | |
| 136 | + | let state = state().await; | |
| 137 | + | let project = state | |
| 138 | + | .projects | |
| 139 | + | .create( | |
| 140 | + | DESKTOP_USER_ID, | |
| 141 | + | NewProject { | |
| 142 | + | name: "Housekeeping".to_owned(), | |
| 143 | + | description: String::new(), | |
| 144 | + | project_type: ProjectType::SideProject, | |
| 145 | + | status: ProjectStatus::Active, | |
| 146 | + | }, | |
| 147 | + | ) | |
| 148 | + | .unwrap(); | |
| 149 | + | state | |
| 150 | + | .tasks | |
| 151 | + | .create( | |
| 152 | + | DESKTOP_USER_ID, | |
| 153 | + | NewTask::builder("Write the thing") | |
| 154 | + | .priority(Priority::High) | |
| 155 | + | .project_id(project.id) | |
| 156 | + | .build(), | |
| 157 | + | ) | |
| 158 | + | .unwrap(); | |
| 159 | + | ||
| 160 | + | let markup = screen(&state); | |
| 161 | + | ||
| 162 | + | assert!(markup.contains("Write the thing"), "{markup}"); | |
| 163 | + | assert!(markup.contains("Housekeeping"), "{markup}"); | |
| 164 | + | // The priority column is the single letter the shipped cell shows. | |
| 165 | + | assert!(markup.contains(">H<"), "{markup}"); | |
| 166 | + | // And the title opens the drawer, which is where the eight controls this | |
| 167 | + | // row does not offer actually live. | |
| 168 | + | assert!(markup.contains("/tasks/"), "{markup}"); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[tokio::test] | |
| 172 | + | async fn a_row_says_whether_the_task_is_available() { | |
| 173 | + | let state = state().await; | |
| 174 | + | let blocker = task(&state, "First"); | |
| 175 | + | let blocked = task(&state, "Second"); | |
| 176 | + | state | |
| 177 | + | .tasks | |
| 178 | + | .add_dependency(DESKTOP_USER_ID, blocked, blocker) | |
| 179 | + | .unwrap(); | |
| 180 | + | ||
| 181 | + | let markup = screen(&state); | |
| 182 | + | ||
| 183 | + | // `Availability`, shared with the board, the project dashboard and the day | |
| 184 | + | // plan. Three surfaces drifted apart by each drawing this themselves; a | |
| 185 | + | // fourth copy here would have been the fourth. | |
| 186 | + | assert!(markup.contains("Blocked"), "{markup}"); | |
| 187 | + | assert!(markup.contains("Unblocks 1"), "{markup}"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[tokio::test] | |
| 191 | + | async fn the_subtask_meter_is_the_progress_column_and_not_also_a_badge() { | |
| 192 | + | let state = state().await; | |
| 193 | + | let id = task(&state, "Has subtasks"); | |
| 194 | + | state.tasks.add_subtask(id, DESKTOP_USER_ID, "one").unwrap(); | |
| 195 | + | state.tasks.add_subtask(id, DESKTOP_USER_ID, "two").unwrap(); | |
| 196 | + | ||
| 197 | + | let markup = screen(&state); | |
| 198 | + | ||
| 199 | + | // `renderTaskRow` draws `0/2` in the description cell *and* a progress bar | |
| 200 | + | // in the progress column: one fact twice, computed twice, able to disagree. | |
| 201 | + | // The meter is the column's, so the badge is gone. | |
| 202 | + | assert!(markup.contains("progress"), "{markup}"); | |
| 203 | + | assert!(!markup.contains("0/2"), "{markup}"); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // The order | |
| 207 | + | ||
| 208 | + | #[tokio::test] | |
| 209 | + | async fn the_default_order_is_the_screens_and_not_the_commands() { | |
| 210 | + | let state = state().await; | |
| 211 | + | task(&state, "Anything"); | |
| 212 | + | ||
| 213 | + | let markup = screen(&state); | |
| 214 | + | ||
| 215 | + | // `list_tasks_filtered` defaults to urgency descending and this screen has | |
| 216 | + | // opened on due ascending since `tasks-filter.js` was written. A port of a | |
| 217 | + | // screen keeps the screen's default. | |
| 218 | + | assert!(markup.contains("ascending"), "{markup}"); | |
| 219 | + | // Nothing in the default address says so, because a default is never | |
| 220 | + | // written: two addresses for one view cannot exist. | |
| 221 | + | assert!(!markup.contains("sort=due"), "{markup}"); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[tokio::test] | |
| 225 | + | async fn a_heading_carries_the_address_that_reorders_by_it() { | |
| 226 | + | let state = state().await; | |
| 227 | + | task(&state, "Anything"); | |
| 228 | + | ||
| 229 | + | let markup = screen(&state); | |
| 230 | + | ||
| 231 | + | // `Column::reorder`'s first consumer. The shipped screen writes `aria-sort` | |
| 232 | + | // onto the heading from module state and rebuilds the list in JS; here the | |
| 233 | + | // heading is an address and the order it names is what the next read gets. | |
| 234 | + | assert!(markup.contains("sort=project"), "{markup}"); | |
| 235 | + | assert!(markup.contains("sort=priority"), "{markup}"); | |
| 236 | + | // Recurrence and progress are not sortable in the build script's | |
| 237 | + | // description, so they carry nothing. | |
| 238 | + | assert!(!markup.contains("sort=recurrence"), "{markup}"); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[tokio::test] | |
| 242 | + | async fn pressing_the_column_already_sorted_flips_it() { | |
| 243 | + | let state = state().await; | |
| 244 | + | task(&state, "Anything"); | |
| 245 | + | ||
| 246 | + | // Due is the default and is ascending, so its own heading offers descending. | |
| 247 | + | // Asserted on the heading's own link rather than on the markup: every row | |
| 248 | + | // control also carries the view it was drawn under, so a bare search for | |
| 249 | + | // `direction=desc` finds the Start button too. | |
| 250 | + | let markup = screen(&state); | |
| 251 | + | assert!( | |
| 252 | + | markup.contains(r#"class="table-sort" href="/tasks/list?direction=desc""#), | |
| 253 | + | "{markup}" | |
| 254 | + | ); | |
| 255 | + | assert!(markup.contains(r#"aria-sort="ascending""#), "{markup}"); | |
| 256 | + | ||
| 257 | + | // Under descending the same heading offers the way back, which is the | |
| 258 | + | // default view and therefore an address with nothing written on it. | |
| 259 | + | let flipped = html(get( | |
| 260 | + | &state, | |
| 261 | + | "/tasks/list", | |
| 262 | + | Params::new().with("direction", "desc"), | |
| 263 | + | )); | |
| 264 | + | assert!( | |
| 265 | + | flipped.contains(r#"class="table-sort" href="/tasks/list""#), | |
| 266 | + | "{flipped}" | |
| 267 | + | ); | |
| 268 | + | assert!(flipped.contains(r#"aria-sort="descending""#), "{flipped}"); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // The view is an address | |
| 272 | + | ||
| 273 | + | #[tokio::test] | |
| 274 | + | async fn a_filter_answers_the_list_alone() { | |
| 275 | + | let state = state().await; | |
| 276 | + | task(&state, "Pending one"); | |
| 277 | + | ||
| 278 | + | let response = get( | |
| 279 | + | &state, | |
| 280 | + | "/tasks/list", | |
| 281 | + | Params::new().with("status", "Completed"), | |
| 282 | + | ); | |
| 283 | + | let Outcome::Fragment { region, .. } = &response.outcome else { | |
| 284 | + | panic!("a filter replaces the list, not the screen"); | |
| 285 | + | }; | |
| 286 | + | assert_eq!(region, "tasks-list"); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[tokio::test] | |
| 290 | + | async fn the_status_filter_cuts_the_list_and_latches_the_chip() { | |
| 291 | + | let state = state().await; | |
| 292 | + | let done = task(&state, "Finished"); | |
| 293 | + | task(&state, "Outstanding"); | |
| 294 | + | state.tasks.complete(done, DESKTOP_USER_ID).unwrap(); | |
| 295 | + | ||
| 296 | + | let pending = screen(&state); | |
| 297 | + | assert!(pending.contains("Outstanding"), "{pending}"); | |
| 298 | + | assert!(!pending.contains("Finished"), "{pending}"); | |
| 299 | + | ||
| 300 | + | let completed = html(get( | |
| 301 | + | &state, | |
| 302 | + | "/tasks", | |
| 303 | + | Params::new().with("status", "Completed"), | |
| 304 | + | )); | |
| 305 | + | assert!(completed.contains("Finished"), "{completed}"); | |
| 306 | + | assert!(!completed.contains("Outstanding"), "{completed}"); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[tokio::test] | |
| 310 | + | async fn a_word_the_filter_does_not_know_is_a_404_and_not_a_different_list() { | |
| 311 | + | let state = state().await; | |
| 312 | + | task(&state, "Anything"); | |
| 313 | + | ||
| 314 | + | // The problems inbox's rule. `TaskStatus::from_str_or_default` would answer | |
| 315 | + | // Pending for "Pendign" and the screen would look like it worked. | |
| 316 | + | for (name, value) in [ | |
| 317 | + | ("status", "Pendign"), | |
| 318 | + | ("priority", "Highest"), | |
| 319 | + | ("sort", "urgncy"), | |
| 320 | + | ("project", "not-a-uuid"), | |
| 321 | + | ] { | |
| 322 | + | let answer = router().handle( | |
| 323 | + | &state, | |
| 324 | + | Request::get("/tasks/list").carrying(Params::new().with(name, value)), | |
| 325 | + | ); | |
| 326 | + | assert!(answer.is_err(), "{name}={value} answered something"); | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[tokio::test] | |
| 331 | + | async fn the_priority_chip_clears_itself_when_it_is_the_one_latched() { | |
| 332 | + | let state = state().await; | |
| 333 | + | task(&state, "Anything"); | |
| 334 | + | ||
| 335 | + | let filtered = html(get( | |
| 336 | + | &state, | |
| 337 | + | "/tasks", | |
| 338 | + | Params::new().with("priority", "High"), | |
| 339 | + | )); | |
| 340 | + | ||
| 341 | + | // Pressing a latched chip clears it, so the way back is always on screen. | |
| 342 | + | // The contacts tag filter's rule, and the problems source filter's. Its | |
| 343 | + | // own href is what carries the clearing, not the markup at large: every | |
| 344 | + | // other control on the screen preserves the filter it was drawn under, so | |
| 345 | + | // `priority=High` appears all over it and should. | |
| 346 | + | assert!( | |
| 347 | + | filtered.contains(r#"<a class="chip latched" data-act aria-current="true" href="/tasks/list" hx-get="/tasks/list" hx-swap="morph">High</a>"#), | |
| 348 | + | "{filtered}" | |
| 349 | + | ); | |
| 350 | + | assert!(filtered.contains("priority=Medium"), "{filtered}"); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | #[tokio::test] | |
| 354 | + | async fn a_filter_change_goes_back_to_one_page() { | |
| 355 | + | let state = state().await; | |
| 356 | + | task(&state, "Anything"); | |
| 357 | + | ||
| 358 | + | let deep = html(get(&state, "/tasks", Params::new().with("shown", "600"))); | |
| 359 | + | ||
| 360 | + | // Carrying `shown` onto a filter would ask for 600 rows of a priority | |
| 361 | + | // holding nine. The mail list's rule. | |
| 362 | + | assert!(!deep.contains("shown=600&"), "{deep}"); | |
| 363 | + | assert!(deep.contains("priority=High"), "{deep}"); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[tokio::test] | |
| 367 | + | async fn the_milestone_control_appears_only_under_a_chosen_project() { | |
| 368 | + | let state = state().await; | |
| 369 | + | let project = state | |
| 370 | + | .projects | |
| 371 | + | .create( | |
| 372 | + | DESKTOP_USER_ID, | |
| 373 | + | NewProject { | |
| 374 | + | name: "Housekeeping".to_owned(), | |
| 375 | + | description: String::new(), | |
| 376 | + | project_type: ProjectType::SideProject, | |
| 377 | + | status: ProjectStatus::Active, | |
| 378 | + | }, | |
| 379 | + | ) | |
| 380 | + | .unwrap(); | |
| 381 | + | state | |
| 382 | + | .milestones | |
| 383 | + | .create( | |
| 384 | + | DESKTOP_USER_ID, | |
| 385 | + | goingson_core::NewMilestone { | |
| 386 | + | project_id: project.id, | |
| 387 | + | name: "Beta".to_owned(), | |
| 388 | + | description: String::new(), | |
| 389 | + | target_date: None, | |
| 390 | + | position: 0, | |
| 391 | + | }, | |
| 392 | + | ) | |
| 393 | + | .unwrap(); | |
| 394 | + | ||
| 395 | + | let unfiltered = screen(&state); | |
| 396 | + | assert!(!unfiltered.contains("Beta"), "{unfiltered}"); | |
| 397 | + | ||
| 398 | + | let scoped = html(get( | |
| 399 | + | &state, | |
| 400 | + | "/tasks", | |
| 401 | + | Params::new().with("project", project.id.to_string()), | |
| 402 | + | )); | |
| 403 | + | // `populateMilestoneFilter`'s rule: a milestone belongs to a project, so | |
| 404 | + | // every project's milestones at once is a control whose options mean | |
| 405 | + | // nothing together. | |
| 406 | + | assert!(scoped.contains("Beta"), "{scoped}"); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // Paging | |
| 410 | + | ||
| 411 | + | #[tokio::test] | |
| 412 | + | async fn the_way_to_more_rows_appears_only_when_there_are_more() { | |
| 413 | + | let state = state().await; | |
| 414 | + | task(&state, "The only one"); | |
| 415 | + | ||
| 416 | + | let short = screen(&state); | |
| 417 | + | assert!(!short.contains("Show more"), "{short}"); | |
| 418 | + | ||
| 419 | + | // `shown` is clamped to at least a page, so a short list never offers it | |
| 420 | + | // however the address is typed. | |
| 421 | + | let typed_low = html(get(&state, "/tasks/list", Params::new().with("shown", "1"))); | |
| 422 | + | assert!(!typed_low.contains("Show more"), "{typed_low}"); | |
| 423 | + | ||
| 424 | + | // Past one page it does, and it names how far along the reader is, which is | |
| 425 | + | // what the shipped count chip says while pages stream in. Finding 2 in the | |
| 426 | + | // module header: this act is what a table cannot say for itself. | |
| 427 | + | for n in 0..super::PAGE { | |
| 428 | + | task(&state, &format!("Filler {n}")); | |
| 429 | + | } | |
| 430 | + | let long = screen(&state); | |
| 431 | + | assert!(long.contains("Show more (200 of 201)"), "{long}"); | |
| 432 | + | assert!(long.contains("shown=400"), "{long}"); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Writes | |
| 436 | + | ||
| 437 | + | #[tokio::test] | |
| 438 | + | async fn starting_a_task_from_the_list_answers_the_list() { | |
| 439 | + | let state = state().await; | |
| 440 | + | let id = task(&state, "Startable"); | |
| 441 | + | ||
| 442 | + | let response = post( | |
| 443 | + | &state, | |
| 444 | + | &format!("/tasks/list/{id}/status"), | |
| 445 | + | Params::new().with("status", "Started"), | |
| 446 | + | Params::new(), | |
| 447 | + | ); | |
| 448 | + | ||
| 449 | + | let Outcome::Fragment { region, .. } = &response.outcome else { | |
| 450 | + | panic!("a row's control replaces the list, not the screen"); | |
| 451 | + | }; | |
| 452 | + | assert_eq!(region, "tasks-list"); | |
| 453 | + | ||
| 454 | + | let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); | |
| 455 | + | assert_eq!(after.status, TaskStatus::Started); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[tokio::test] | |
| 459 | + | async fn the_write_and_the_filter_are_both_called_status_and_cannot_reach_each_other() { | |
| 460 | + | let state = state().await; | |
| 461 | + | let id = task(&state, "Startable"); | |
| 462 | + | ||
| 463 | + | // The arrangement the problems inbox and the mail screen paid for on one | |
| 464 | + | // afternoon: `payload` and `carried` are different bags, so the target of | |
| 465 | + | // the write is not read back as the view it happened in. | |
| 466 | + | let response = post( | |
| 467 | + | &state, | |
| 468 | + | &format!("/tasks/list/{id}/status"), | |
| 469 | + | Params::new().with("status", "Completed"), | |
| 470 | + | Params::new().with("status", "Pending"), | |
| 471 | + | ); | |
| 472 | + | ||
| 473 | + | let markup = html(response); | |
| 474 | + | // Answered with the Pending list it was pressed in, which no longer holds | |
| 475 | + | // the completed task -- not with the Completed list the write named. | |
| 476 | + | assert!(!markup.contains("Startable"), "{markup}"); | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | #[tokio::test] | |
| 480 | + | async fn pressing_a_status_a_task_is_already_in_writes_nothing() { | |
| 481 | + | let state = state().await; | |
| 482 | + | let id = task(&state, "Stationary"); | |
| 483 | + | ||
| 484 | + | let response = post( | |
| 485 | + | &state, | |
| 486 | + | &format!("/tasks/list/{id}/status"), | |
| 487 | + | Params::new().with("status", "Pending"), | |
| 488 | + | Params::new(), | |
| 489 | + | ); | |
| 490 | + | ||
| 491 | + | let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); | |
| 492 | + | assert_eq!(after.status, TaskStatus::Pending); | |
| 493 | + | // No toast, because nothing happened. A row can be pressed while the list | |
| 494 | + | // it was drawn in is stale, and a repeated press must not complete a task | |
| 495 | + | // twice and mint a second recurrence. | |
| 496 | + | assert!(response.notice.is_none(), "{:?}", response.notice); | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | #[tokio::test] | |
| 500 | + | async fn completing_a_recurring_task_shows_the_successor_it_minted() { |
Lines truncated