| 20 |
20 |
|
//! Not here, and each a decision rather than an omission:
|
| 21 |
21 |
|
//!
|
| 22 |
22 |
|
//! - **Drag to reschedule.** Moving an existing item is a continuous gesture
|
| 23 |
|
- |
//! and the vocabulary names no such thing. quasicoherent `e41079b2` is the
|
| 24 |
|
- |
//! live question, and Max's direction there is that placing pre-portioned
|
| 25 |
|
- |
//! blocks replaces the paint interaction entirely. Either way it is an
|
| 26 |
|
- |
//! interaction and not a drawing, so it does not block this screen.
|
| 27 |
|
- |
//! - **Drag to paint a new block.** Same question, same task.
|
|
23 |
+ |
//! and the vocabulary names no such thing. Stepping by a fixed amount is
|
|
24 |
+ |
//! what replaced it, on a row's own controls.
|
|
25 |
+ |
//! - **Drag to paint a new block.** Placement replaced it: a task is put on
|
|
26 |
+ |
//! the day at a named slot, at the size its estimate gives it. Both
|
|
27 |
+ |
//! quasicoherent `e41079b2` and goingson `fa9fe9ed` are settled, and neither
|
|
28 |
+ |
//! asked for a gesture. See [`placing`].
|
| 28 |
29 |
|
//!
|
| 29 |
|
- |
//! Both are the host's: a described screen draws the day and hands the two
|
| 30 |
|
- |
//! gestures over.
|
|
30 |
+ |
//! Nothing here is waiting on a continuous-input member. The vocabulary the
|
|
31 |
+ |
//! screen needed was [`Placed`] to draw a block and [`Act::asking`] to ask for
|
|
32 |
+ |
//! one before writing it, and both existed.
|
| 31 |
33 |
|
|
| 32 |
34 |
|
// Handlers take their request by value because `quasi_router::Handler` is a
|
| 33 |
35 |
|
// plain `fn(&S, Request)` pointer, so the signature is the router's.
|
| 34 |
36 |
|
#![allow(clippy::needless_pass_by_value)]
|
| 35 |
37 |
|
|
| 36 |
|
- |
use chrono::{Local, NaiveDate};
|
|
38 |
+ |
use chrono::{Local, NaiveDate, TimeZone, Utc};
|
| 37 |
39 |
|
use goingson_core::TimelineItem;
|
| 38 |
|
- |
use makeover_layout::{Placement, Tone, Track};
|
| 39 |
|
- |
use quasi_router::screen::{Act, Placed, Row, Tag};
|
|
40 |
+ |
use makeover_layout::{FieldKind, Placement, Tone, Track};
|
|
41 |
+ |
use quasi_router::screen::{Act, Choice, Field, Placed, Row, Tag};
|
| 40 |
42 |
|
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
| 41 |
43 |
|
|
| 42 |
|
- |
use crate::commands::{DayPlanningResponse, day_plan};
|
|
44 |
+ |
use crate::commands::{DayPlanningResponse, TaskResponse, day_plan};
|
| 43 |
45 |
|
use crate::state::{AppState, DESKTOP_USER_ID};
|
| 44 |
46 |
|
|
| 45 |
47 |
|
#[cfg(test)]
|
| 63 |
65 |
|
day_plan(state, date).map_err(|error| RouteError::internal(error.to_string()))
|
| 64 |
66 |
|
}
|
| 65 |
67 |
|
|
|
68 |
+ |
/// The hour the axis opens on, and the slot a placement is offered at first.
|
|
69 |
+ |
///
|
|
70 |
+ |
/// One number for both, because they answer the same question: which part of
|
|
71 |
+ |
/// the day the reader is most likely to mean.
|
|
72 |
+ |
const FOCUS_MINUTES: i32 = 9 * 60;
|
|
73 |
+ |
|
|
74 |
+ |
/// The grid a placement lands on, in minutes.
|
|
75 |
+ |
///
|
|
76 |
+ |
/// The same 15 the move controls step by and the same 96 slots the ruler
|
|
77 |
+ |
/// draws. Shared deliberately: a block placed off the grid could not be
|
|
78 |
+ |
/// stepped back onto it.
|
|
79 |
+ |
const SLOT_MINUTES: i32 = 15;
|
|
80 |
+ |
|
|
81 |
+ |
/// A day, in minutes. The bound on both a slot and a duration.
|
|
82 |
+ |
const DAY_MINUTES: i32 = 24 * 60;
|
|
83 |
+ |
|
|
84 |
+ |
/// The length offered for a task nobody has estimated.
|
|
85 |
+ |
///
|
|
86 |
+ |
/// One slot, the smallest honest guess. It is a pre-filled answer and not a
|
|
87 |
+ |
/// silent default: dismissing the ask places nothing and writes nothing, which
|
|
88 |
+ |
/// is the whole of what goingson `fa9fe9ed` ruled.
|
|
89 |
+ |
const UNESTIMATED_MINUTES: i32 = 15;
|
|
90 |
+ |
|
|
91 |
+ |
/// Every slot of the day, as the choices a placement picks between.
|
|
92 |
+ |
///
|
|
93 |
+ |
/// The argument is discrete and this is what makes it so. A wall-clock field
|
|
94 |
+ |
/// would let the answer name another date, which the address already carries,
|
|
95 |
+ |
/// and would then have to be checked against it.
|
|
96 |
+ |
fn slots() -> Vec<Choice> {
|
|
97 |
+ |
(0..DAY_MINUTES / SLOT_MINUTES)
|
|
98 |
+ |
.map(|slot| {
|
|
99 |
+ |
let minutes = slot * SLOT_MINUTES;
|
|
100 |
+ |
Choice::new(
|
|
101 |
+ |
minutes.to_string(),
|
|
102 |
+ |
format!("{:02}:{:02}", minutes / 60, minutes % 60),
|
|
103 |
+ |
)
|
|
104 |
+ |
})
|
|
105 |
+ |
.collect()
|
|
106 |
+ |
}
|
|
107 |
+ |
|
|
108 |
+ |
/// Putting a task on the day, as the control that does it.
|
|
109 |
+ |
///
|
|
110 |
+ |
/// A task is placed at its own size: the block is as long as the estimate, so
|
|
111 |
+ |
/// the day shows the work rather than a row of identical stubs. A task with no
|
|
112 |
+ |
/// estimate is placeable anyway, and **placing it is what sets the estimate**.
|
|
113 |
+ |
/// goingson `fa9fe9ed` ruled (d) over defaulting silently, over refusing the
|
|
114 |
+ |
/// placement, and over drawing a guess differently. The planner is the one place a
|
|
115 |
+ |
/// person is already thinking about how long the thing takes, so it is the
|
|
116 |
+ |
/// right place to be asked.
|
|
117 |
+ |
///
|
|
118 |
+ |
/// Both halves are [`Act::asking`], which existed: a control that asks for a
|
|
119 |
+ |
/// value before it acts. The second field is offered only when there is
|
|
120 |
+ |
/// nothing to offer instead, so a task that carries an estimate is placed in
|
|
121 |
+ |
/// one answer rather than being asked to confirm what it already says.
|
|
122 |
+ |
fn placing(task: &TaskResponse, date: NaiveDate) -> Act {
|
|
123 |
+ |
let mut act = Act::new(
|
|
124 |
+ |
"Place on the day",
|
|
125 |
+ |
Action::post(format!("/day/{date}/schedule/{}/place", task.id)),
|
|
126 |
+ |
)
|
|
127 |
+ |
.asking(
|
|
128 |
+ |
Field::select("at", "Start at", slots())
|
|
129 |
+ |
.value(FOCUS_MINUTES.to_string())
|
|
130 |
+ |
.required(),
|
|
131 |
+ |
);
|
|
132 |
+ |
|
|
133 |
+ |
if task.estimated_minutes.is_none_or(|minutes| minutes <= 0) {
|
|
134 |
+ |
act = act.asking(
|
|
135 |
+ |
Field {
|
|
136 |
+ |
min: Some("1".to_owned()),
|
|
137 |
+ |
max: Some(DAY_MINUTES.to_string()),
|
|
138 |
+ |
value: Some(UNESTIMATED_MINUTES.to_string()),
|
|
139 |
+ |
..Field::new(FieldKind::Number, "minutes", "How long it takes")
|
|
140 |
+ |
}
|
|
141 |
+ |
.hint("Placing it records this as the task's estimate.")
|
|
142 |
+ |
.required(),
|
|
143 |
+ |
);
|
|
144 |
+ |
}
|
|
145 |
+ |
|
|
146 |
+ |
act
|
|
147 |
+ |
}
|
|
148 |
+ |
|
| 66 |
149 |
|
/// One item as a row, without its placement.
|
| 67 |
150 |
|
///
|
| 68 |
151 |
|
/// The whole body is vocabulary that existed before this screen: a title, the
|
| 107 |
190 |
|
// the same shape as the milestone row's reorder: a control that is on
|
| 108 |
191 |
|
// screen beats a gesture nobody discovers.
|
| 109 |
192 |
|
//
|
| 110 |
|
- |
// The drag itself stays refused, and it is quasicoherent `e41079b2`:
|
| 111 |
|
- |
// nothing names a gesture across a grid or what it produces.
|
|
193 |
+ |
// The drag stays refused and the question is closed, not open:
|
|
194 |
+ |
// quasicoherent `e41079b2` ruled that pre-portioned placement replaces
|
|
195 |
+ |
// it. Placement decides where a block starts; stepping is how it moves
|
|
196 |
+ |
// afterwards, so the two are not alternatives.
|
| 112 |
197 |
|
let step = |minutes: i32, label: &str| {
|
| 113 |
198 |
|
Act::new(
|
| 114 |
199 |
|
label,
|
| 194 |
279 |
|
|
| 195 |
280 |
|
/// The unscheduled pool.
|
| 196 |
281 |
|
///
|
| 197 |
|
- |
/// Everything due today that is not on the axis yet. Each row opens its task;
|
| 198 |
|
- |
/// putting one *onto* the day is the interaction `e41079b2` decides, so the
|
| 199 |
|
- |
/// pool draws and does not place.
|
| 200 |
|
- |
fn pool(response: &DayPlanningResponse) -> Node {
|
|
282 |
+ |
/// Everything due today that is not on the axis yet. Each row opens its task
|
|
283 |
+ |
/// and carries the control that puts it on the day; see [`placing`] for what
|
|
284 |
+ |
/// that asks for and why.
|
|
285 |
+ |
fn pool(response: &DayPlanningResponse, date: NaiveDate) -> Node {
|
| 201 |
286 |
|
let rows: Vec<Row> = response
|
| 202 |
287 |
|
.unscheduled_tasks
|
| 203 |
288 |
|
.iter()
|
| 227 |
312 |
|
if let Some(marker) = super::Availability::reported(task).frees_marker() {
|
| 228 |
313 |
|
row = row.token(marker);
|
| 229 |
314 |
|
}
|
| 230 |
|
- |
row.activate(Action::get(format!("/tasks/{}", task.id)))
|
|
315 |
+ |
row.act(placing(task, date))
|
|
316 |
+ |
.activate(Action::get(format!("/tasks/{}", task.id)))
|
| 231 |
317 |
|
})
|
| 232 |
318 |
|
.collect();
|
| 233 |
319 |
|
|
| 295 |
381 |
|
// by project. Nothing else on this screen states today's total, since
|
| 296 |
382 |
|
// two claims about it would be one too many.
|
| 297 |
383 |
|
.with(super::time_tracking::summary(state)?)
|
| 298 |
|
- |
.with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response)))
|
|
384 |
+ |
.with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response, date)))
|
| 299 |
385 |
|
.into())
|
| 300 |
386 |
|
}
|
| 301 |
387 |
|
|
| 375 |
461 |
|
timeline_fragment(state, date)
|
| 376 |
462 |
|
}
|
| 377 |
463 |
|
|
| 378 |
|
- |
/// The axis, re-read. What both writes answer with.
|
|
464 |
+ |
/// Put a task on the day at a named slot, and answer with the axis.
|
|
465 |
+ |
///
|
|
466 |
+ |
/// The slot arrives as minutes from local midnight, which is what [`slots`]
|
|
467 |
+ |
/// offers. The length is the task's estimate; a task with none is asked for
|
|
468 |
+ |
/// one and **the answer becomes the estimate**, per goingson `fa9fe9ed`. An
|
|
469 |
+ |
/// ask that was dismissed sends nothing, so nothing is placed and nothing is
|
|
470 |
+ |
/// written. That is the distinction between the ruling and the silent default
|
|
471 |
+ |
/// it beat, and it is the easy half to lose.
|
|
472 |
+ |
///
|
|
473 |
+ |
/// A slot already occupied is accepted rather than refused. The conflict pass
|
|
474 |
+ |
/// reports clashes and the axis draws them in lanes; this screen reports
|
|
475 |
+ |
/// rather than prevents, the same as it does for an out-of-order plan.
|
|
476 |
+ |
fn place(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
|
|
477 |
+ |
let date = date_of(&request)?;
|
|
478 |
+ |
let id = task_of(&request)?;
|
|
479 |
+ |
|
|
480 |
+ |
let at: i32 = request
|
|
481 |
+ |
.payload
|
|
482 |
+ |
.get("at")
|
|
483 |
+ |
.and_then(|raw| raw.parse().ok())
|
|
484 |
+ |
.filter(|minutes| (0..DAY_MINUTES).contains(minutes))
|
|
485 |
+ |
.ok_or_else(|| RouteError::not_found("place it at a slot of this day"))?;
|
|
486 |
+ |
|
|
487 |
+ |
let task = state
|
|
488 |
+ |
.tasks
|
|
489 |
+ |
.get_by_id(id, DESKTOP_USER_ID)
|
|
490 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
491 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
492 |
+ |
|
|
493 |
+ |
// A stored zero is no estimate: `is_over_estimate` reads it that way and a
|
|
494 |
+ |
// block of no length is not a thing the axis can draw.
|
|
495 |
+ |
let estimated = task.estimated_minutes.filter(|minutes| *minutes > 0);
|
|
496 |
+ |
let minutes = match estimated {
|
|
497 |
+ |
Some(estimate) => estimate,
|
|
498 |
+ |
None => request
|
|
499 |
+ |
.payload
|
|
500 |
+ |
.get("minutes")
|
|
501 |
+ |
.and_then(|raw| raw.parse().ok())
|
|
502 |
+ |
.filter(|asked| (1..=DAY_MINUTES).contains(asked))
|
|
503 |
+ |
.ok_or_else(|| RouteError::not_found("say how long it takes"))?,
|
|
504 |
+ |
};
|
|
505 |
+ |
|
|
506 |
+ |
// Placing an unestimated task is what estimates it. Built from the task
|
|
507 |
+ |
// just read, so the estimate is the only thing that changes; anything left
|
|
508 |
+ |
// out of an `UpdateTask` is cleared, and being put on the day is not a
|
|
509 |
+ |
// reason to lose a task's tags.
|
|
510 |
+ |
if estimated.is_none() {
|
|
511 |
+ |
state
|
|
512 |
+ |
.tasks
|
|
513 |
+ |
.update(
|
|
514 |
+ |
id,
|
|
515 |
+ |
DESKTOP_USER_ID,
|
|
516 |
+ |
goingson_core::UpdateTask {
|
|
517 |
+ |
project_id: task.project_id,
|
|
518 |
+ |
milestone_id: task.milestone_id,
|
|
519 |
+ |
contact_id: task.contact_id,
|
|
520 |
+ |
title: task.title.clone(),
|
|
521 |
+ |
description: task.description.clone(),
|
|
522 |
+ |
status: task.status.clone(),
|
|
523 |
+ |
priority: task.priority.clone(),
|
|
524 |
+ |
due: task.due,
|
|
525 |
+ |
tags: task.tags.clone(),
|
|
526 |
+ |
recurrence: task.recurrence.clone(),
|
|
527 |
+ |
recurrence_rule: task.recurrence_rule.clone(),
|
|
528 |
+ |
urgency: task.urgency,
|
|
529 |
+ |
scheduled_start: task.scheduled_start,
|
|
530 |
+ |
scheduled_duration: task.scheduled_duration,
|
|
531 |
+ |
estimated_minutes: Some(minutes),
|
|
532 |
+ |
},
|
|
533 |
+ |
)
|
|
534 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?
|
|
535 |
+ |
.ok_or_else(|| RouteError::not_found("no such task"))?;
|
|
536 |
+ |
}
|
|
537 |
+ |
|
|
538 |
+ |
// The slot is a wall-clock time on the day the address names, so the
|
|
539 |
+ |
// instant it stands for is a local one. A clock time the local day skips
|
|
540 |
+ |
// over is refused rather than nudged: on a spring-forward morning 02:30 is
|
|
541 |
+ |
// a slot nobody can start at, and silently placing the block at 03:30
|
|
542 |
+ |
// would be this screen answering a question it was not asked.
|
|
543 |
+ |
let civil = date
|
|
544 |
+ |
.and_hms_opt(
|
|
545 |
+ |
u32::try_from(at / 60).unwrap_or(0),
|
|
546 |
+ |
u32::try_from(at % 60).unwrap_or(0),
|
|
547 |
+ |
0,
|
|
548 |
+ |
)
|
|
549 |
+ |
.ok_or_else(|| RouteError::not_found("not a time of day"))?;
|
|
550 |
+ |
let start = Local
|
|
551 |
+ |
.from_local_datetime(&civil)
|
|
552 |
+ |
.earliest()
|
|
553 |
+ |
.ok_or_else(|| RouteError::conflict("the clock skips that time on this day"))?
|
|
554 |
+ |
.with_timezone(&Utc);
|
|
555 |
+ |
|
|
556 |
+ |
// The command's own body, for [`move_scheduled`]'s reason: the linked
|
|
557 |
+ |
// event is written beside the task row, with a compensating undo between
|
|
558 |
+ |
// them, and a second copy of that here would drift.
|
|
559 |
+ |
crate::commands::day_planning::schedule_task_now(state, id, start, Some(minutes))
|
|
560 |
+ |
.map_err(|error| RouteError::internal(error.to_string()))?;
|
|
561 |
+ |
|
|
562 |
+ |
timeline_fragment(state, date)
|
|
563 |
+ |
}
|
|
564 |
+ |
|
|
565 |
+ |
/// The axis and the pool, re-read. What every write on this screen answers
|
|
566 |
+ |
/// with.
|
|
567 |
+ |
///
|
|
568 |
+ |
/// Both, because every write here moves a task between them: placing takes one
|
|
569 |
+ |
/// out of the pool, unscheduling puts one back, and a move leaves the pool
|
|
570 |
+ |
/// alone but is sent through the same door. A fragment that refreshed only the
|
|
571 |
+ |
/// axis would leave the pool showing a task that is now on the day, which is
|
|
572 |
+ |
/// the screen disagreeing with itself.
|
| 379 |
573 |
|
fn timeline_fragment(state: &AppState, date: NaiveDate) -> Result<Response, RouteError> {
|
| 380 |
574 |
|
let response = plan(state, date)?;
|
| 381 |
|
- |
Ok(Response::fragment(
|
| 382 |
|
- |
"day-timeline",
|
| 383 |
|
- |
timeline(&response, date),
|
| 384 |
|
- |
))
|
|
575 |
+ |
Ok(
|
|
576 |
+ |
Response::fragment("day-timeline", timeline(&response, date))
|
|
577 |
+ |
.also("day-pool", pool(&response, date)),
|
|
578 |
+ |
)
|
| 385 |
579 |
|
}
|
| 386 |
580 |
|
|
| 387 |
581 |
|
/// This screen's routes.
|
| 390 |
584 |
|
.get("/day", day)
|
| 391 |
585 |
|
.get("/day/{date}", day)
|
| 392 |
586 |
|
.get("/day/{date}/timeline", timeline_only)
|
|
587 |
+ |
.post("/day/{date}/schedule/{task}/place", place)
|
| 393 |
588 |
|
.post("/day/{date}/schedule/{task}/move", move_scheduled)
|
| 394 |
589 |
|
.post("/day/{date}/schedule/{task}/unschedule", unschedule)
|
| 395 |
590 |
|
}
|