|
1 |
+ |
//! The task overview, described rather than built.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! Third screen ported, chosen by weight from `task-overview.js`: 15 `esc()`
|
|
6 |
+ |
//! calls, and the fattest single screen left after contacts. The shipped screen
|
|
7 |
+ |
//! is `frontend/js/task-overview.js` exactly as before; see [the module
|
|
8 |
+ |
//! above](super) for why both exist at once.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! This is the first port with something the vocabulary is meant not to reach.
|
|
11 |
+ |
//! The completion heatmap is a month grid of counts, and a description
|
|
12 |
+ |
//! expressive enough to produce one is a widget library wearing a description's
|
|
13 |
+ |
//! name. It gets a [`RegionKind::Bespoke`] and stops there, which is decision 4
|
|
14 |
+ |
//! working rather than a shortfall. Five other things the description could not
|
|
15 |
+ |
//! say are recorded where they bite.
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! # The shape
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! - `GET /tasks/{id}` — the whole overview.
|
|
20 |
+ |
//! - `POST /tasks/{id}/complete` — mark it done.
|
|
21 |
+ |
//! - `POST /tasks/{id}/delete` — delete it.
|
|
22 |
+ |
//! - `POST /tasks/{id}/subtasks` — add one.
|
|
23 |
+ |
//! - `POST /tasks/{id}/subtasks/{sub}/toggle` — tick or untick one.
|
|
24 |
+ |
//! - `POST /tasks/{id}/notes` — add a note.
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! Every described control reaches one of those, which is the standard the
|
|
27 |
+ |
//! contacts port set. The one control left out rather than dangled is Edit: it
|
|
28 |
+ |
//! opens `form-modal.js` over the drawer, and a modal form over a screen is a
|
|
29 |
+ |
//! second arrangement this screen would have to describe before it could offer
|
|
30 |
+ |
//! it. Recorded rather than faked.
|
|
31 |
+ |
|
|
32 |
+ |
// Handlers take their params by value because `quasi_router::Handler` is a
|
|
33 |
+ |
// plain `fn(&S, Params)` pointer, so the signature is the router's and not a
|
|
34 |
+ |
// choice made here. Same allow, for the same reason, as quasi-axum's tests.
|
|
35 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
36 |
+ |
|
|
37 |
+ |
use chrono::{DateTime, Local, Utc};
|
|
38 |
+ |
use goingson_core::{Annotation, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession};
|
|
39 |
+ |
use quasi_router::screen::{Act, Field, Row, Tag};
|
|
40 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
41 |
+ |
|
|
42 |
+ |
use crate::commands::{StreakInfo, compute_streak};
|
|
43 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
|
44 |
+ |
|
|
45 |
+ |
#[cfg(test)]
|
|
46 |
+ |
mod tests;
|
|
47 |
+ |
|
|
48 |
+ |
/// The tone a status badge wears.
|
|
49 |
+ |
///
|
|
50 |
+ |
/// The Rust half of `task-overview.js:STATUS_COLOR`, which maps green/blue/muted
|
|
51 |
+ |
/// onto the same three live statuses. `Deleted` never reaches a rendered screen
|
|
52 |
+ |
/// and takes the same neutral as pending rather than a tone of its own.
|
|
53 |
+ |
const fn status_tone(status: &TaskStatus) -> makeover_layout::Tone {
|
|
54 |
+ |
match status {
|
|
55 |
+ |
TaskStatus::Completed => makeover_layout::Tone::Success,
|
|
56 |
+ |
TaskStatus::Started => makeover_layout::Tone::Info,
|
|
57 |
+ |
TaskStatus::Pending | TaskStatus::Deleted => makeover_layout::Tone::Neutral,
|
|
58 |
+ |
}
|
|
59 |
+ |
}
|
|
60 |
+ |
|
|
61 |
+ |
/// The tone a priority badge wears.
|
|
62 |
+ |
///
|
|
63 |
+ |
/// `PRIORITY_COLOR`'s red/yellow/muted. Low is neutral rather than a cool
|
|
64 |
+ |
/// colour for the reason `Tone`'s own docs give: a tone on everything is a tone
|
|
65 |
+ |
/// on nothing.
|
|
66 |
+ |
const fn priority_tone(priority: &Priority) -> makeover_layout::Tone {
|
|
67 |
+ |
match priority {
|
|
68 |
+ |
Priority::High => makeover_layout::Tone::Danger,
|
|
69 |
+ |
Priority::Medium => makeover_layout::Tone::Warning,
|
|
70 |
+ |
Priority::Low => makeover_layout::Tone::Neutral,
|
|
71 |
+ |
}
|
|
72 |
+ |
}
|
|
73 |
+ |
|
|
74 |
+ |
/// A short local date, the way every list on this screen writes one.
|
|
75 |
+ |
///
|
|
76 |
+ |
/// `toLocaleDateString('en-US', { month: 'short', day: 'numeric' })` in three
|
|
77 |
+ |
/// places in the JS. One function here, because three call sites that format a
|
|
78 |
+ |
/// date three ways is how a screen ends up looking assembled.
|
|
79 |
+ |
fn short_date(at: DateTime<Utc>) -> String {
|
|
80 |
+ |
at.with_timezone(&Local).format("%b %-d").to_string()
|
|
81 |
+ |
}
|
|
82 |
+ |
|
|
83 |
+ |
/// The task a route was addressed at.
|
|
84 |
+ |
fn task_id(params: &quasi_router::Params) -> Result<TaskId, RouteError> {
|
|
85 |
+ |
let raw = params
|
|
86 |
+ |
.get("id")
|
|
87 |
+ |
.ok_or_else(|| RouteError::not_found("no task id"))?;
|
|
88 |
+ |
Ok(TaskId::from(
|
|
89 |
+ |
uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
|
|
90 |
+ |
))
|
|
91 |
+ |
}
|
|
92 |
+ |
|
|
93 |
+ |
/// Read the task, or answer 404.
|
|
94 |
+ |
///
|
|
95 |
+ |
/// A deleted task is a 404 here even though `get_by_id` still returns it.
|
|
96 |
+ |
/// `TaskCrud::delete` is a soft delete — it sets the status and `list_all`
|
|
97 |
+ |
/// filters the row out — so a screen addressing one by id would otherwise
|
|
98 |
+ |
/// render a deleted task as an ordinary one, complete with controls offering to
|
|
99 |
+ |
/// complete it. The JS never met this because it closes the drawer on delete
|
|
100 |
+ |
/// and does not re-fetch. An address that outlives the thing it addresses is
|
|
101 |
+ |
/// exactly what a router has to answer for.
|
|
102 |
+ |
fn load(state: &AppState, id: TaskId) -> Result<Task, RouteError> {
|
|
103 |
+ |
let task = state
|
|
104 |
+ |
.tasks
|
|
105 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
106 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
107 |
+ |
.filter(|task| task.status != TaskStatus::Deleted)
|
|
108 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
109 |
+ |
Ok(task)
|
|
110 |
+ |
}
|
|
111 |
+ |
|
|
112 |
+ |
/// The streak stats, for a task that has a recurrence chain.
|
|
113 |
+ |
///
|
|
114 |
+ |
/// `None` for a one-off, which is what makes the whole completion-history
|
|
115 |
+ |
/// section absent rather than empty. Reuses `compute_streak` from the command
|
|
116 |
+ |
/// layer rather than restating it: the streak table is already Rust, and two
|
|
117 |
+ |
/// copies of a rule that decides what a number means is worse than an import
|
|
118 |
+ |
/// across module lines.
|
|
119 |
+ |
fn streak_for(state: &AppState, task: &Task) -> Result<Option<StreakInfo>, RouteError> {
|
|
120 |
+ |
if !task.has_recurrence() && task.recurrence_parent_id.is_none() {
|
|
121 |
+ |
return Ok(None);
|
|
122 |
+ |
}
|
|
123 |
+ |
let root = task.recurrence_parent_id.unwrap_or(task.id);
|
|
124 |
+ |
let chain = state
|
|
125 |
+ |
.tasks
|
|
126 |
+ |
.list_recurrence_chain(root, DESKTOP_USER_ID)
|
|
127 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
128 |
+ |
Ok(Some(compute_streak(&chain)))
|
|
129 |
+ |
}
|
|
130 |
+ |
|
|
131 |
+ |
/// One streak figure.
|
|
132 |
+ |
///
|
|
133 |
+ |
/// # The first finding
|
|
134 |
+ |
///
|
|
135 |
+ |
/// **A figure with a label is not a row, and there is nothing else to make it
|
|
136 |
+ |
/// out of.** The four stats are the shape every dashboard has: a large value
|
|
137 |
+ |
/// over a small caption, laid out as a strip. `Row` is a list item — primary
|
|
138 |
+ |
/// text with trailing facts — so this reads them backwards, putting the caption
|
|
139 |
+ |
/// first because that is what a row's primary slot means. Four of them then
|
|
140 |
+ |
/// render as a list where the JS renders a strip of tiles.
|
|
141 |
+ |
///
|
|
142 |
+ |
/// Everything survives and the arrangement does not, which is the same shape as
|
|
143 |
+ |
/// the token finding that `RowPart::Tokens` closed. Filed as makeover-layout
|
|
144 |
+ |
/// `93c6a174`.
|
|
145 |
+ |
fn stat(label: &str, value: impl Into<String>) -> Row {
|
|
146 |
+ |
Row::new(label).meta(value)
|
|
147 |
+ |
}
|
|
148 |
+ |
|
|
149 |
+ |
/// The completion-history section, for a recurring task.
|
|
150 |
+ |
fn habit_section(streak: &StreakInfo, task_id: TaskId) -> Vec<Node> {
|
|
151 |
+ |
vec![
|
|
152 |
+ |
Node::section("Completion History"),
|
|
153 |
+ |
Node::list([
|
|
154 |
+ |
stat("Current Streak", format!("{}d", streak.current_streak)),
|
|
155 |
+ |
stat("Best Streak", format!("{}d", streak.best_streak)),
|
|
156 |
+ |
stat(
|
|
157 |
+ |
"Completion Rate",
|
|
158 |
+ |
format!("{}%", streak.completion_rate_30d.round() as i64),
|
|
159 |
+ |
),
|
|
160 |
+ |
stat(
|
|
161 |
+ |
"Total Completed",
|
|
162 |
+ |
format!("{}/{}", streak.total_completed, streak.total_instances),
|
|
163 |
+ |
),
|
|
164 |
+ |
]),
|
|
165 |
+ |
// The heatmap. A month grid of completion counts is exactly what
|
|
166 |
+ |
// `Region::Bespoke` was named for: the description says a thing called
|
|
167 |
+ |
// `task-heatmap` goes here and says nothing else, and
|
|
168 |
+ |
// `task-overview.js:renderHeatmap` fills it. Not a workaround and not a
|
|
169 |
+ |
// gap — a description able to produce a calendar grid is a widget
|
|
170 |
+ |
// library with a description's name on it.
|
|
171 |
+ |
//
|
|
172 |
+ |
// The id carries the task so the filling code knows which chain to
|
|
173 |
+ |
// render without asking the screen.
|
|
174 |
+ |
Node::Region(Slot::bespoke(
|
|
175 |
+ |
format!("task-heatmap-{task_id}"),
|
|
176 |
+ |
"task-heatmap",
|
|
177 |
+ |
)),
|
|
178 |
+ |
]
|
|
179 |
+ |
}
|
|
180 |
+ |
|
|
181 |
+ |
/// The badges across the top: status, priority, and whatever else is true.
|
|
182 |
+ |
///
|
|
183 |
+ |
/// Tokens rather than text, so a status keeps its tone. The three conditional
|
|
184 |
+ |
/// ones are the JS's, in its order: focus, overdue, snoozed.
|
|
185 |
+ |
fn badges(task: &Task) -> Vec<Node> {
|
|
186 |
+ |
let mut out = vec![
|
|
187 |
+ |
Node::Token(Tag::badge(task.status.as_str()).tone(status_tone(&task.status))),
|
|
188 |
+ |
Node::Token(Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority))),
|
|
189 |
+ |
];
|
|
190 |
+ |
if task.is_focus {
|
|
191 |
+ |
out.push(Node::Token(
|
|
192 |
+ |
Tag::badge("Focus").tone(makeover_layout::Tone::Info),
|
|
193 |
+ |
));
|
|
194 |
+ |
}
|
|
195 |
+ |
if task.is_overdue() {
|
|
196 |
+ |
out.push(Node::Token(
|
|
197 |
+ |
Tag::badge("Overdue").tone(makeover_layout::Tone::Danger),
|
|
198 |
+ |
));
|
|
199 |
+ |
}
|
|
200 |
+ |
if task.is_snoozed() {
|
|
201 |
+ |
out.push(Node::Token(
|
|
202 |
+ |
Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning),
|
|
203 |
+ |
));
|
|
204 |
+ |
}
|
|
205 |
+ |
out
|
|
206 |
+ |
}
|
|
207 |
+ |
|
|
208 |
+ |
/// The labelled facts under the badges.
|
|
209 |
+ |
///
|
|
210 |
+ |
/// `Project: X`, `Due: Y` and the rest, each a row whose primary is the label
|
|
211 |
+ |
/// and whose meta is the value. The JS bolds the label inside a sentence; a row
|
|
212 |
+ |
/// is the nearest thing the vocabulary has to a definition list, and unlike the
|
|
213 |
+ |
/// stats above the label really is the primary here.
|
|
214 |
+ |
fn details(task: &Task) -> Vec<Row> {
|
|
215 |
+ |
let mut rows = Vec::new();
|
|
216 |
+ |
if let Some(project) = &task.project_name {
|
|
217 |
+ |
rows.push(Row::new("Project").meta(project));
|
|
218 |
+ |
}
|
|
219 |
+ |
if task.due.is_some() {
|
|
220 |
+ |
rows.push(Row::new("Due").meta(task.due_formatted()));
|
|
221 |
+ |
}
|
|
222 |
+ |
if task.has_recurrence() {
|
|
223 |
+ |
rows.push(Row::new("Recurrence").meta(task.recurrence.as_str()));
|
|
224 |
+ |
}
|
|
225 |
+ |
if let Some(contact) = &task.contact_name {
|
|
226 |
+ |
rows.push(Row::new("Contact").meta(contact));
|
|
227 |
+ |
}
|
|
228 |
+ |
if !task.tags.is_empty() {
|
|
229 |
+ |
// Tokens, because a tag is a badge in the JS and `RowPart::Tokens`
|
|
230 |
+ |
// exists now to keep it one.
|
|
231 |
+ |
let mut row = Row::new("Tags");
|
|
232 |
+ |
for tag in &task.tags {
|
|
233 |
+ |
row = row.token(Tag::badge(tag));
|
|
234 |
+ |
}
|
|
235 |
+ |
rows.push(row);
|
|
236 |
+ |
}
|
|
237 |
+ |
rows
|
|
238 |
+ |
}
|
|
239 |
+ |
|
|
240 |
+ |
/// The metadata section.
|
|
241 |
+ |
///
|
|
242 |
+ |
/// # The second finding, and it is one already open
|
|
243 |
+ |
///
|
|
244 |
+ |
/// **A description carries text and this task carries markdown.**
|
|
245 |
+ |
/// `TaskResponse::description_html` is `docengine::render_standard` and the JS
|
|
246 |
+ |
/// drops it into a `.markdown-content` div. Nothing in the vocabulary names rich
|
|
247 |
+ |
/// text and it must not be smuggled in as a string the renderer trusts. The raw
|
|
248 |
+ |
/// description goes in as text, correct and lossy.
|
|
249 |
+ |
///
|
|
250 |
+ |
/// Recorded here rather than filed again: this is makeover-layout `25822137`,
|
|
251 |
+ |
/// found on the projects card. What this port adds is that it now has two
|
|
252 |
+ |
/// consumers rather than one, which is the evidence that note asked for.
|
|
253 |
+ |
fn metadata(task: &Task) -> Vec<Node> {
|
|
254 |
+ |
let mut out = badges(task);
|
|
255 |
+ |
if !task.description.is_empty() {
|
|
256 |
+ |
// Text, not the rendered HTML. See the note above.
|
|
257 |
+ |
out.push(Node::text(&task.description));
|
|
258 |
+ |
}
|
|
259 |
+ |
let rows = details(task);
|
|
260 |
+ |
if !rows.is_empty() {
|
|
261 |
+ |
out.push(Node::list(rows));
|
|
262 |
+ |
}
|
|
263 |
+ |
out
|
|
264 |
+ |
}
|
|
265 |
+ |
|
|
266 |
+ |
/// One subtask.
|
|
267 |
+ |
///
|
|
268 |
+ |
/// # The third finding
|
|
269 |
+ |
///
|
|
270 |
+ |
/// **A tick that means something has no route.** `Row::selected` says whether a
|
|
271 |
+ |
/// row is ticked and whether it can be; nothing says what ticking it *calls*.
|
|
272 |
+ |
/// That was right for the case it was added for — goingson's bulk-selection
|
|
273 |
+ |
/// checkboxes are client-side state feeding a later bulk action — and a subtask
|
|
274 |
+ |
/// is the other case: the tick is the write.
|
|
275 |
+ |
///
|
|
276 |
+ |
/// So the tick here is drawn and inert, and the toggle is an `Act` beside it.
|
|
277 |
+ |
/// Every fact survives, the affordance does not: the user clicks a button
|
|
278 |
+ |
/// labelled "Done" where the shipped screen has a checkbox they click directly.
|
|
279 |
+ |
/// Filed as makeover-layout `14612ed8`.
|
|
280 |
+ |
///
|
|
281 |
+ |
/// A linked subtask is disabled in the JS because its state follows the task it
|
|
282 |
+ |
/// links to. `Act::disabled` says that, so this one is described exactly.
|
|
283 |
+ |
fn subtask_row(task: TaskId, subtask: &Subtask) -> Row {
|
|
284 |
+ |
let mut row = Row::new(&subtask.text).selectable(subtask.is_completed);
|
|
285 |
+ |
if subtask.linked_task_id.is_some() {
|
|
286 |
+ |
row = row.token(Tag::badge("Linked"));
|
|
287 |
+ |
}
|
|
288 |
+ |
|
|
289 |
+ |
let toggle = Act::new(
|
|
290 |
+ |
if subtask.is_completed { "Undo" } else { "Done" },
|
|
291 |
+ |
Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id)),
|
|
292 |
+ |
);
|
|
293 |
+ |
row.act(if subtask.linked_task_id.is_some() {
|
|
294 |
+ |
toggle.disabled()
|
|
295 |
+ |
} else {
|
|
296 |
+ |
toggle
|
|
297 |
+ |
})
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
/// The subtasks section.
|
|
301 |
+ |
///
|
|
302 |
+ |
/// # The fourth finding
|
|
303 |
+ |
///
|
|
304 |
+ |
/// **A heading cannot carry a count, and a proportion cannot be drawn.** The JS
|
|
305 |
+ |
/// writes "Subtasks 3/7" with the count in its own small span, and a slim
|
|
306 |
+ |
/// progress bar under it. `Node::section` takes one string, so the count is
|
|
307 |
+ |
/// concatenated into the heading; nothing names a proportion, so the bar is
|
|
308 |
+ |
/// gone and its number is in the heading too.
|
|
309 |
+ |
///
|
|
310 |
+ |
/// Two separate gaps that land in one place, and only one is worth a member. A
|
|
311 |
+ |
/// heading with a trailing count is a nice-to-have. A proportion is not: three
|
|
312 |
+ |
/// screens draw one (subtasks, time tracking, the project dashboard), it has a
|
|
313 |
+ |
/// tone in two of them, and "78%" as text is a different thing from a bar the
|
|
314 |
+ |
/// eye reads without counting. Filed as makeover-layout `d0b58239`.
|
|
315 |
+ |
fn subtasks_section(task: &Task) -> Vec<Node> {
|
|
316 |
+ |
let done = task.subtasks_completed();
|
|
317 |
+ |
let total = task.subtask_count();
|
|
318 |
+ |
|
|
319 |
+ |
let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))];
|
|
320 |
+ |
if total > 0 {
|
|
321 |
+ |
out.push(Node::list(
|
|
322 |
+ |
task.subtasks
|
|
323 |
+ |
.iter()
|
|
324 |
+ |
.map(|subtask| subtask_row(task.id, subtask)),
|
|
325 |
+ |
));
|
|
326 |
+ |
}
|
|
327 |
+ |
out.push(Node::Form {
|
|
328 |
+ |
action: Action::post(format!("/tasks/{}/subtasks", task.id)),
|
|
329 |
+ |
submit: "Add".to_owned(),
|
|
330 |
+ |
fields: vec![{
|
|
331 |
+ |
let mut field =
|
|
332 |
+ |
Field::new(makeover_layout::FieldKind::Text, "text", "Subtask").required();
|
|
333 |
+ |
field.placeholder = Some("Add subtask...".to_owned());
|
|
334 |
+ |
field
|
|
335 |
+ |
}],
|
|
336 |
+ |
});
|
|
337 |
+ |
out
|
|
338 |
+ |
}
|
|
339 |
+ |
|
|
340 |
+ |
/// One tracked session.
|
|
341 |
+ |
fn session_row(session: &TimeSession) -> Row {
|
|
342 |
+ |
let started = session.started_at.with_timezone(&Local);
|
|
343 |
+ |
Row::new(format!(
|
|
344 |
+ |
"{} {}",
|
|
345 |
+ |
short_date(session.started_at),
|
|
346 |
+ |
started.format("%-I:%M %p")
|
|
347 |
+ |
))
|
|
348 |
+ |
.meta(match session.duration_minutes {
|
|
349 |
+ |
Some(minutes) => format!("{minutes}m"),
|
|
350 |
+ |
// A session with no end is one running right now, which is a fact
|
|
351 |
+ |
// about the task and not a missing value.
|
|
352 |
+ |
None => "active".to_owned(),
|
|
353 |
+ |
})
|
|
354 |
+ |
}
|
|
355 |
+ |
|
|
356 |
+ |
/// The time-tracking section.
|
|
357 |
+ |
///
|
|
358 |
+ |
/// The same proportion gap as the subtasks section, and here it also loses a
|
|
359 |
+ |
/// tone: the JS colours the bar red past the estimate and green under it, so
|
|
360 |
+ |
/// over-running is visible without reading. `is_over_estimate` is in the
|
|
361 |
+ |
/// heading's text instead.
|
|
362 |
+ |
fn time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node> {
|
|
363 |
+ |
let tracked = format!("{}m tracked", task.actual_minutes);
|
|
364 |
+ |
let label = match task.estimated_minutes {
|
|
365 |
+ |
Some(estimate) if estimate > 0 => {
|
|
366 |
+ |
let over = if task.is_over_estimate() {
|
|
367 |
+ |
", over"
|
|
368 |
+ |
} else {
|
|
369 |
+ |
""
|
|
370 |
+ |
};
|
|
371 |
+ |
format!("{tracked} / {estimate}m est{over}")
|
|
372 |
+ |
}
|
|
373 |
+ |
_ => tracked,
|
|
374 |
+ |
};
|
|
375 |
+ |
|
|
376 |
+ |
let mut out = vec![Node::section(format!("Time Tracking {label}"))];
|
|
377 |
+ |
if !sessions.is_empty() {
|
|
378 |
+ |
out.push(Node::list(sessions.iter().map(session_row)));
|
|
379 |
+ |
}
|
|
380 |
+ |
out
|
|
381 |
+ |
}
|
|
382 |
+ |
|
|
383 |
+ |
/// One note.
|
|
384 |
+ |
fn annotation_row(annotation: &Annotation) -> Row {
|
|
385 |
+ |
let at = annotation.timestamp.with_timezone(&Local);
|
|
386 |
+ |
Row::new(&annotation.note).meta(format!(
|
|
387 |
+ |
"{} {}",
|
|
388 |
+ |
short_date(annotation.timestamp),
|
|
389 |
+ |
at.format("%-I:%M %p")
|
|
390 |
+ |
))
|
|
391 |
+ |
}
|
|
392 |
+ |
|
|
393 |
+ |
/// The notes section.
|
|
394 |
+ |
fn notes_section(task: &Task) -> Vec<Node> {
|
|
395 |
+ |
let mut out = vec![Node::section(format!("Notes {}", task.annotations.len()))];
|
|
396 |
+ |
if !task.annotations.is_empty() {
|
|
397 |
+ |
out.push(Node::list(task.annotations.iter().map(annotation_row)));
|
|
398 |
+ |
}
|
|
399 |
+ |
out.push(Node::Form {
|
|
400 |
+ |
action: Action::post(format!("/tasks/{}/notes", task.id)),
|
|
401 |
+ |
submit: "Add".to_owned(),
|
|
402 |
+ |
fields: vec![{
|
|
403 |
+ |
let mut field = Field::new(makeover_layout::FieldKind::Text, "note", "Note").required();
|
|
404 |
+ |
field.placeholder = Some("Add note...".to_owned());
|
|
405 |
+ |
field
|
|
406 |
+ |
}],
|
|
407 |
+ |
});
|
|
408 |
+ |
out
|
|
409 |
+ |
}
|
|
410 |
+ |
|
|
411 |
+ |
/// The whole screen.
|
|
412 |
+ |
///
|
|
413 |
+ |
/// Built here rather than inside the route because every write answers with it,
|
|
414 |
+ |
/// for the reason the projects screen gives: a write lands in more than one
|
|
415 |
+ |
/// section and a `Response` names one region.
|
|
416 |
+ |
///
|
|
417 |
+ |
/// # The fifth finding, left out rather than faked
|
|
418 |
+ |
///
|
|
419 |
+ |
/// **Edit is not offered.** `tasks.openEdit` opens `form-modal.js` over the
|
|
420 |
+ |
/// drawer. `Region::Modal` names the place, but a screen that offers a control
|
|
421 |
+ |
/// which opens a form over itself has to describe two arrangements at once, and
|
|
422 |
+ |
/// the router answers one screen at a time. Describing the edit form as its own
|
|
423 |
+ |
/// address is the shape that fits, and it is a screen of its own rather than a
|
|
424 |
+ |
/// control on this one.
|
|
425 |
+ |
fn screen(state: &AppState, id: TaskId) -> Result<Screen, RouteError> {
|
|
426 |
+ |
let task = load(state, id)?;
|
|
427 |
+ |
let sessions = state
|
|
428 |
+ |
.tasks
|
|
429 |
+ |
.list_time_sessions(id, DESKTOP_USER_ID)
|
|
430 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
431 |
+ |
let streak = streak_for(state, &task)?;
|
|
432 |
+ |
|
|
433 |
+ |
let mut band = Slot::new("task-band", RegionKind::Band).with(Node::page(&task.title));
|
|
434 |
+ |
if task.status != TaskStatus::Completed {
|
|
435 |
+ |
band = band.with(Node::act(
|
|
436 |
+ |
"Complete",
|
|
437 |
+ |
Action::post(format!("/tasks/{id}/complete")),
|
|
438 |
+ |
));
|
|
439 |
+ |
}
|
|
440 |
+ |
band = band.with(Node::Act(
|
|
441 |
+ |
Act::new("Delete", Action::post(format!("/tasks/{id}/delete")))
|
|
442 |
+ |
.tone(makeover_layout::Tone::Danger),
|
|
443 |
+ |
));
|
|
444 |
+ |
|
|
445 |
+ |
let mut pane = Slot::new("task-overview", RegionKind::Pane);
|
|
446 |
+ |
if let Some(streak) = &streak {
|
|
447 |
+ |
pane = pane.extend(habit_section(streak, id));
|
|
448 |
+ |
}
|
|
449 |
+ |
pane = pane.extend(metadata(&task));
|
|
450 |
+ |
// The JS hides the subtasks section on a completed task with none, on the
|
|
451 |
+ |
// grounds that there is nothing to add one for any more.
|
|
452 |
+ |
if !task.subtasks.is_empty() || task.status != TaskStatus::Completed {
|
|
453 |
+ |
pane = pane.extend(subtasks_section(&task));
|
|
454 |
+ |
}
|
|
455 |
+ |
pane = pane.extend(time_section(&task, &sessions));
|
|
456 |
+ |
pane = pane.extend(notes_section(&task));
|
|
457 |
+ |
|
|
458 |
+ |
Ok(Screen::list_detail("Task", false).with(band).with(pane))
|
|
459 |
+ |
}
|
|
460 |
+ |
|
|
461 |
+ |
/// The whole overview.
|
|
462 |
+ |
fn overview(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
463 |
+ |
Ok(screen(state, task_id(¶ms)?)?.into())
|
|
464 |
+ |
}
|
|
465 |
+ |
|
|
466 |
+ |
/// Answer a write with the screen it happened on, re-read.
|
|
467 |
+ |
///
|
|
468 |
+ |
/// Re-read rather than patched in memory, for the reason the contacts port
|
|
469 |
+ |
/// gives: the write is the database's to confirm, and a screen rebuilt from
|
|
470 |
+ |
/// what the handler hoped happened is how a screen disagrees with its own
|
|
471 |
+ |
/// storage.
|
|
472 |
+ |
fn wrote(state: &AppState, id: TaskId) -> Result<Response, RouteError> {
|
|
473 |
+ |
Ok(screen(state, id)?.into())
|
|
474 |
+ |
}
|
|
475 |
+ |
|
|
476 |
+ |
/// Mark the task complete.
|
|
477 |
+ |
///
|
|
478 |
+ |
/// `complete` handles the recurring case itself, minting the next instance, so
|
|
479 |
+ |
/// this does not branch on recurrence. Answering with the same address then
|
|
480 |
+ |
/// shows the completed instance rather than the new one, which matches what the
|
|
481 |
+ |
/// JS does: it re-opens the task it was showing.
|
|
482 |
+ |
fn complete(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
483 |
+ |
let id = task_id(¶ms)?;
|
|
484 |
+ |
state
|
|
485 |
+ |
.tasks
|
|
486 |
+ |
.complete(id, DESKTOP_USER_ID)
|
|
487 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
488 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
489 |
+ |
wrote(state, id)
|
|
490 |
+ |
}
|
|
491 |
+ |
|
|
492 |
+ |
/// Delete the task.
|
|
493 |
+ |
///
|
|
494 |
+ |
/// # The sixth finding
|
|
495 |
+ |
///
|
|
496 |
+ |
/// **Nothing describes going somewhere else.** Every answer replaces a region
|
|
497 |
+ |
/// or the screen in place. Deleting the thing a screen is about is the first
|
|
498 |
+ |
/// case where the right answer is a *different* screen — the JS closes the
|
|
499 |
+ |
/// drawer and leaves the list underneath — and the description has no way to
|
|
500 |
+ |
/// say "show that address now". The projects and contacts writes never met this
|