Skip to main content

max / goingson

11.5 KB · 352 lines History Blame Raw
1 //! The task board, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Every card fact is ordinary vocabulary: a title is a row's primary, the
6 //! project and the due date are meta, the blocked and unblocks markers are
7 //! tokens, subtask progress is a `Meter`, opening a card is `activate` and the
8 //! context menu is `menu`. A column is a heading, a count and a list. That the
9 //! three columns are **peers** is a row whose every member asks to fill: they
10 //! divide the room equally by `Width::Fill`'s own rule, and no member chooses
11 //! what another shows. A board described as list-detail or sidebar-content is a
12 //! lie about the screen.
13 //!
14 //! It was `RegionKind::Columns` until that variant was retired (quasicoherent
15 //! `cf981aaa`). The row says the same thing and one thing more: `Wrap` is what
16 //! the board does when three columns no longer fit across, which the variant
17 //! could not state and every renderer had to invent.
18 //!
19 //! # The shape
20 //!
21 //! - `GET /board` — the three columns.
22 //! - `POST /board/{id}/status` — move a card, carrying `to`.
23 //!
24 //! # Dragging
25 //!
26 //! A drop's *effect* is `set status to Started`: a discrete action with a
27 //! discrete argument, which the vocabulary can say. The drag is the affordance,
28 //! it is presentation, and the host keeps it.
29 //!
30 //! So each card offers its two moves as acts. A webview may wire those to a
31 //! drop target and a terminal may bind them to keys; both are honouring the
32 //! same description.
33 //!
34 //! # `to` and not `status`
35 //!
36 //! The target state travels as `to` because `status` is what a column *is*.
37
38 // Handlers take their request by value because `quasi_router::Handler` is a
39 // plain `fn(&S, Request)` pointer, so the signature is the router's.
40 #![allow(clippy::needless_pass_by_value)]
41
42 use goingson_core::{Priority, Task, TaskId, TaskStatus};
43 use makeover_layout::Tone;
44 use quasi_declare::declare;
45 use quasi_router::screen::{Meter, Tag};
46 use quasi_router::{Node, Response, RouteError, Router, Screen};
47
48 use crate::state::{AppState, DESKTOP_USER_ID};
49
50 #[cfg(test)]
51 mod tests;
52
53 /// The three columns, in the order the board reads.
54 ///
55 /// `tasks-kanban.js`'s `COLUMNS`, and the same order. `Deleted` is not a column
56 /// because a deleted task is not on the board; the JS drops it by only grouping
57 /// the three it knows.
58 ///
59 /// Named members rather than a tuple, for `policy`'s reason: a description
60 /// names what it draws, and `.1` is not a name.
61 pub(super) struct Lane {
62 /// The status a card in it has.
63 pub status: TaskStatus,
64 /// The region it draws in.
65 pub id: &'static str,
66 /// What the heading calls it, and what a move sends.
67 pub label: &'static str,
68 }
69
70 const COLUMNS: &[Lane] = &[
71 Lane {
72 status: TaskStatus::Pending,
73 id: "pending",
74 label: "Pending",
75 },
76 Lane {
77 status: TaskStatus::Started,
78 id: "started",
79 label: "Started",
80 },
81 Lane {
82 status: TaskStatus::Completed,
83 id: "done",
84 label: "Completed",
85 },
86 ];
87
88 /// The tone a priority wears. `tasks.rs`'s `priority_tone`, which is the same
89 /// mapping and is not re-derived here on purpose.
90 const fn priority_tone(priority: &Priority) -> Tone {
91 match priority {
92 Priority::High => Tone::Danger,
93 Priority::Medium => Tone::Warning,
94 Priority::Low => Tone::Neutral,
95 }
96 }
97
98 /// The task a route was addressed at.
99 fn task_id(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
100 let raw = request
101 .captures
102 .get("id")
103 .ok_or_else(|| RouteError::not_found("no task id"))?;
104 Ok(TaskId::from(
105 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
106 ))
107 }
108
109 /// Where a card is being moved to.
110 ///
111 /// `to`, not `status`. Unparseable is a 404 rather than a silent no-op: a
112 /// control naming a column that does not exist is a wiring mistake, and
113 /// answering it with an unchanged board hides it.
114 fn target(request: &quasi_router::Request) -> Result<TaskStatus, RouteError> {
115 match request
116 .payload
117 .get("to")
118 .or_else(|| request.carried.get("to"))
119 {
120 Some("Pending") => Ok(TaskStatus::Pending),
121 Some("Started") => Ok(TaskStatus::Started),
122 Some("Completed") => Ok(TaskStatus::Completed),
123 _ => Err(RouteError::not_found("no such column")),
124 }
125 }
126
127 /// What a priority's badge reads.
128 fn priority_label(task: &Task) -> &'static str {
129 match task.priority {
130 Priority::High => "High",
131 Priority::Medium => "Medium",
132 Priority::Low => "Low",
133 }
134 }
135
136 /// Whether the card carries a due date.
137 fn has_due(task: &Task) -> bool {
138 task.due.is_some()
139 }
140
141 /// That date, short. R9: read whether or not it is placed.
142 fn due_label(task: &Task) -> String {
143 task.due.map_or_else(String::new, |due| {
144 due.with_timezone(&chrono::Local)
145 .format("%b %-d")
146 .to_string()
147 })
148 }
149
150 /// Whether it has passed.
151 ///
152 /// A judgment the app makes and the renderer cannot, so it travels as a tone
153 /// rather than as a class the way `kanban-card-due.overdue` does.
154 fn due_tone(task: &Task) -> Tone {
155 let overdue = task
156 .due
157 .is_some_and(|due| due < chrono::Utc::now() && task.status != TaskStatus::Completed);
158 if overdue { Tone::Danger } else { Tone::Neutral }
159 }
160
161 /// Whether the card names a project.
162 fn has_project(task: &Task) -> bool {
163 !project(task).is_empty()
164 }
165
166 /// That project, or nothing.
167 fn project(task: &Task) -> &str {
168 task.project_name.as_deref().unwrap_or_default()
169 }
170
171 /// How many subtasks are done, and how many there are.
172 ///
173 /// Suppliers because a cast is arithmetic and the form admits none. `Meter`
174 /// says done-of-total; the JS says a percentage width, which is the same fact
175 /// already divided.
176 fn subtasks_done(task: &Task) -> u32 {
177 u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX)
178 }
179
180 /// See [`subtasks_done`].
181 fn subtasks_all(task: &Task) -> u32 {
182 u32::try_from(task.subtask_count()).unwrap_or(u32::MAX)
183 }
184
185 /// Whether there is any progress to draw.
186 ///
187 /// Absent rather than empty when a task has no subtasks, so a card does not
188 /// carry a bar at zero.
189 fn has_subtasks(task: &Task) -> bool {
190 task.subtask_count() > 0
191 }
192
193 /// Whether this lane is one the card can be moved to.
194 ///
195 /// Its own is left out: dropping a card where it already is is the one case
196 /// `onDrop` bails on, and offering it would be an act that does nothing.
197 fn elsewhere(task: &Task, lane: &Lane) -> bool {
198 lane.status != task.status
199 }
200
201 declare! {
202 /// One card: a task, and everything a glance at the board should say.
203 ///
204 /// Whether the card is available to work on is `Availability`'s, not this
205 /// screen's: `tasks-kanban.js` drew the same marker by calling the task
206 /// row's own renderer, and a second copy is how the board card and the task
207 /// row drifted apart to begin with.
208 shape card(task: &Task) -> Row;
209
210 row &task.title {
211 token Tag::badge(priority_label(task)).tone(priority_tone(&task.priority));
212 meta project(task) when has_project(task);
213
214 for marker in super::Availability::of(task).marker().into_iter() {
215 token marker;
216 }
217
218 token Tag::badge(due_label(task)).tone(due_tone(task)) when has_due(task);
219 meter Meter::new(subtasks_done(task), subtasks_all(task)).tone(Tone::Success)
220 when has_subtasks(task);
221
222 // The moves this card offers, which is what a drop does.
223 for lane in COLUMNS {
224 act "Move to {lane.label}" to post "/board/{task.id}/status"
225 with "to" lane.label
226 when elsewhere(task, lane);
227 }
228
229 activate to get "/tasks/{task.id}";
230 }
231 }
232
233 /// The cards in one lane.
234 ///
235 /// A supplier because `filter` takes a closure, and it hands back borrowed
236 /// tasks, which is not a vocabulary type and so is not counted.
237 fn in_lane<'a>(tasks: &'a [Task], lane: &Lane) -> Vec<&'a Task> {
238 tasks
239 .iter()
240 .filter(|task| task.status == lane.status)
241 .collect()
242 }
243
244 /// How many are in it. A number with a caption is what a figure is; the JS drew
245 /// a bare span.
246 fn tally(tasks: &[Task], lane: &Lane) -> String {
247 in_lane(tasks, lane).len().to_string()
248 }
249
250 /// Whether the lane is empty.
251 fn is_empty(tasks: &[Task], lane: &Lane) -> bool {
252 in_lane(tasks, lane).is_empty()
253 }
254
255 declare! {
256 /// One column: its name, how many are in it, and the cards.
257 ///
258 /// The read happens once in the handler and every lane is drawn from the
259 /// same list, which is also a fix: this asked the store once per column
260 /// before, so the board read the whole task table three times per request.
261 shape column(tasks: &[Task], lane: &Lane) -> Slot;
262
263 region lane.id as Pane {
264 section lane.label;
265 text tally(tasks, lane);
266
267 empty "No tasks" when is_empty(tasks, lane);
268 list {
269 for task in in_lane(tasks, lane) {
270 include card(task);
271 }
272 } when not is_empty(tasks, lane);
273 }
274 }
275
276 declare! {
277 /// The board itself, as one region of peer columns.
278 shape board_region(tasks: &[Task]) -> Slot;
279
280 region "board" as Group {
281 across Wrap {
282 for lane in COLUMNS {
283 beside Essential Fill include column(tasks, lane);
284 }
285 }
286 }
287 }
288
289 /// Every task the board draws from, read once.
290 fn everything(state: &AppState) -> Result<Vec<Task>, RouteError> {
291 state
292 .tasks
293 .list_all(DESKTOP_USER_ID)
294 .map_err(|error| RouteError::internal(error.to_string()))
295 }
296
297 /// The whole board.
298 fn board(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
299 // The board is a mode of the Tasks place rather than a place of its
300 // own: `index.html` draws it behind a `data-mode="board"` toggle inside
301 // Tasks, so the Tasks tab stays lit while it is showing.
302 Ok(Screen::list_detail("Board", false)
303 .at_place(super::shell::TASKS)
304 .with(board_region(&everything(state)?))
305 .into())
306 }
307
308 /// Move a card, and answer the board re-read.
309 ///
310 /// Re-read rather than patched, for the reason the problems triage is: the card
311 /// leaves one column and joins another, and completing a recurring task creates
312 /// its successor somewhere else on the board. Only the store knows what the
313 /// board looks like afterwards.
314 fn move_card(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
315 let id = task_id(&request)?;
316 let to = target(&request)?;
317
318 let task = state
319 .tasks
320 .get_by_id(id, DESKTOP_USER_ID)
321 .map_err(|error| RouteError::internal(error.to_string()))?
322 .filter(|task| task.status != TaskStatus::Deleted)
323 .ok_or_else(|| RouteError::not_found("no such task"))?;
324
325 // Already there. The JS bails here too, and it matters more through a route
326 // than through a drop: a repeated POST must not complete a task twice and
327 // mint a second recurrence.
328 if task.status == to {
329 return Ok(Response::fragment(
330 "board",
331 Node::Region(board_region(&everything(state)?)),
332 ));
333 }
334
335 // Three different writes, which is what the JS does and is not incidental.
336 // The task list offers the same three from a row, so they live in
337 // [`super::move_to`] rather than here.
338 let message = super::move_to(state, &task, &to)?;
339
340 Ok(
341 Response::fragment("board", Node::Region(board_region(&everything(state)?)))
342 .toast(Tone::Success, message),
343 )
344 }
345
346 /// This screen's routes.
347 pub fn routes(router: Router<AppState>) -> Router<AppState> {
348 router
349 .get("/board", board)
350 .post("/board/{id}/status", move_card)
351 }
352