Skip to main content

max / goingson

25.4 KB · 664 lines History Blame Raw
1 //! The day view, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A timeline is [`Track`](makeover_layout::Track): where a thing starts and
6 //! how long it lasts, which is two integers rather than a component library.
7 //!
8 //! # The shape
9 //!
10 //! - `GET /day` — today.
11 //! - `GET /day/{date}` — one day, `YYYY-MM-DD`.
12 //! - `GET /day/{date}/timeline` — the axis alone, which is what stepping
13 //! between days replaces.
14 //!
15 //! # What is drawn here, and what the host keeps
16 //!
17 //! Drawn here: the axis, everything on it, the conflict tone, the all-day
18 //! strip, the unscheduled pool and the vacation banner.
19 //!
20 //! Not here, and each a decision rather than an omission:
21 //!
22 //! - **Drag to reschedule.** Moving an existing item is a continuous gesture
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`].
29 //!
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.
33
34 // Handlers take their request by value because `quasi_router::Handler` is a
35 // plain `fn(&S, Request)` pointer, so the signature is the router's.
36 #![allow(clippy::needless_pass_by_value)]
37
38 use chrono::{Local, NaiveDate, TimeZone, Utc};
39 use goingson_core::TimelineItem;
40 use makeover_layout::{Placement, Tone, Track};
41 use quasi_declare::declare;
42 use quasi_router::screen::{Choice, Tag};
43 use quasi_router::{Response, RouteError, Router, Slot};
44
45 use crate::commands::{ContextResponse, DayPlanningResponse, TaskResponse, day_plan};
46 use crate::state::{AppState, DESKTOP_USER_ID};
47
48 #[cfg(test)]
49 mod tests;
50
51 /// The day a route was addressed at, or today.
52 ///
53 /// A missing capture is `/day`, which is today. An unparseable one is a 404
54 /// rather than a silent fallback to today: `/day/yesterday` is an address that
55 /// names nothing, and answering it with today's plan would be this screen
56 /// deciding it knows better than the URL.
57 fn date_of(request: &quasi_router::Request) -> Result<NaiveDate, RouteError> {
58 let Some(raw) = request.captures.get("date") else {
59 return Ok(Local::now().date_naive());
60 };
61 NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|_| RouteError::not_found("not a date"))
62 }
63
64 /// The plan, or an internal error.
65 fn plan(state: &AppState, date: NaiveDate) -> Result<DayPlanningResponse, RouteError> {
66 day_plan(state, date).map_err(|error| RouteError::internal(error.to_string()))
67 }
68
69 /// The hour the axis opens on, and the slot a placement is offered at first.
70 ///
71 /// One number for both, because they answer the same question: which part of
72 /// the day the reader is most likely to mean.
73 const FOCUS_MINUTES: u16 = 9 * 60;
74
75 /// The grid a placement lands on, in minutes.
76 ///
77 /// The same 15 the move controls step by and the same 96 slots the ruler
78 /// draws. Shared deliberately: a block placed off the grid could not be
79 /// stepped back onto it.
80 const SLOT_MINUTES: i32 = 15;
81
82 /// A day, in minutes. The bound on both a slot and a duration.
83 const DAY_MINUTES: i32 = 24 * 60;
84
85 /// The length offered for a task nobody has estimated.
86 ///
87 /// One slot, the smallest honest guess. It is a pre-filled answer and not a
88 /// silent default: dismissing the ask places nothing and writes nothing, which
89 /// is the whole of what goingson `fa9fe9ed` ruled.
90 const UNESTIMATED_MINUTES: i32 = 15;
91
92 /// Every slot of the day, as the choices a placement picks between.
93 ///
94 /// The argument is discrete and this is what makes it so. A wall-clock field
95 /// would let the answer name another date, which the address already carries,
96 /// and would then have to be checked against it.
97 fn slots() -> Vec<Choice> {
98 (0..DAY_MINUTES / SLOT_MINUTES)
99 .map(|slot| {
100 let minutes = slot * SLOT_MINUTES;
101 Choice::new(
102 minutes.to_string(),
103 format!("{:02}:{:02}", minutes / 60, minutes % 60),
104 )
105 })
106 .collect()
107 }
108
109 /// Whether the task already says how long it takes.
110 ///
111 /// A stored zero is no estimate: `is_over_estimate` reads it that way and a
112 /// block of no length is not a thing the axis can draw.
113 fn estimated(task: &TaskResponse) -> bool {
114 task.estimated_minutes.is_some_and(|minutes| minutes > 0)
115 }
116
117 declare! {
118 /// Putting a task on the day, as the control that does it.
119 ///
120 /// A task is placed at its own size: the block is as long as the estimate,
121 /// so the day shows the work rather than a row of identical stubs. A task
122 /// with no estimate is placeable anyway, and **placing it is what sets the
123 /// estimate**. goingson `fa9fe9ed` ruled (d) over defaulting silently, over
124 /// refusing the placement, and over drawing a guess differently. The
125 /// planner is the one place a person is already thinking about how long the
126 /// thing takes, so it is the right place to be asked.
127 ///
128 /// Both halves are [`Act::asking`]: a control that asks for a value before
129 /// it acts. The second question is asked only when there is nothing to ask
130 /// it about instead, so a task that carries an estimate is placed in one
131 /// answer rather than being asked to confirm what it already says.
132 shape placing(task: &TaskResponse, date: NaiveDate) -> Act;
133
134 act "Place on the day" to post "/day/{date}/schedule/{task.id}/place" {
135 field Select "at" "Start at" {
136 options slots();
137 value FOCUS_MINUTES.to_string();
138 required;
139 }
140
141 field Number "minutes" "How long it takes" unless estimated(task) {
142 within "1" DAY_MINUTES.to_string();
143 value UNESTIMATED_MINUTES.to_string();
144 hint "Placing it records this as the task's estimate.";
145 required;
146 }
147 }
148 }
149
150 /// One thing on the day, and the two facts the plan worked out for it.
151 struct Entry {
152 item: TimelineItem,
153 /// Whether it covers any of the same time as something else.
154 conflicted: bool,
155 /// Where it sits on the axis, and for how long.
156 ///
157 /// Both already computed by the command, in the units [`Placement`] wants.
158 /// Nothing was added to the backend for this, which is the sign that what
159 /// the vocabulary was missing was the ability to *say* it.
160 placement: Placement,
161 }
162
163 /// One unscheduled task, with what the plan says about it.
164 struct Waiting {
165 task: TaskResponse,
166 /// What it still waits on, said in words.
167 gate: Option<String>,
168 /// Whether the plan puts it out of order.
169 out_of_order: bool,
170 }
171
172 /// Everything the day view draws, read once.
173 struct Day {
174 date: NaiveDate,
175 /// What sits on the axis, with where it sits.
176 on_axis: Vec<Entry>,
177 /// What covers the whole day, drawn above the axis.
178 covering: Vec<Entry>,
179 /// Everything due today that is not on the axis yet.
180 pool: Vec<Waiting>,
181 /// The states framing the day.
182 contexts: Vec<ContextResponse>,
183 }
184
185 /// Read the day.
186 ///
187 /// The conflict pairs the backend computes become a set, because a row needs to
188 /// know only whether it is in one and `detect_conflicts` answers in pairs.
189 fn read(state: &AppState, date: NaiveDate) -> Result<Day, RouteError> {
190 let response = plan(state, date)?;
191
192 let clashing: std::collections::HashSet<_> = response
193 .conflicts
194 .iter()
195 .flat_map(|conflict| [conflict.item1_id, conflict.item2_id])
196 .collect();
197
198 let (all_day, on_axis): (Vec<TimelineItem>, Vec<TimelineItem>) = response
199 .timeline_items
200 .into_iter()
201 // An item covering the whole column is not on the axis: a bar over all
202 // 24 hours papers over every real appointment. That is geometry and
203 // nothing more; a context is its own record and never a timeline item.
204 .partition(|item| item.is_all_day);
205
206 let placed = |item: TimelineItem, conflicted: bool| Entry {
207 placement: Placement::new(
208 u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0),
209 u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1),
210 ),
211 conflicted,
212 item,
213 };
214
215 let pool = response
216 .unscheduled_tasks
217 .into_iter()
218 .map(|task| {
219 let gate = response.gates.get(&task.id);
220 Waiting {
221 gate: gate.and_then(|gate| {
222 gate.after.first().map(|first| {
223 if gate.after.len() > 1 {
224 format!("after {} +{}", first.title, gate.after.len() - 1)
225 } else {
226 format!("after {}", first.title)
227 }
228 })
229 }),
230 out_of_order: gate.is_some_and(|gate| gate.out_of_order),
231 task,
232 }
233 })
234 .collect();
235
236 Ok(Day {
237 date,
238 on_axis: on_axis
239 .into_iter()
240 .map(|item| {
241 let conflicted = clashing.contains(&item.id);
242 placed(item, conflicted)
243 })
244 .collect(),
245 covering: all_day
246 .into_iter()
247 .map(|item| placed(item, false))
248 .collect(),
249 pool,
250 contexts: response.contexts,
251 })
252 }
253
254 declare! {
255 /// One item as a row, without its placement.
256 ///
257 /// The whole body is vocabulary that existed before this screen: a title,
258 /// the project it belongs to, a tone, and a chip when the item continues
259 /// past the edge of the day. That is the measurement the timeline refusal
260 /// never took.
261 ///
262 /// An event that runs over midnight is one row and two bars, and the bar
263 /// this day draws is clipped to this day. The chips say the clipping
264 /// happened, in words rather than with a drawing.
265 ///
266 /// A focus block is a different kind of thing from an appointment. A row
267 /// carries no tone of its own -- deliberately, since a whole line in a
268 /// colour is a slab -- so the fact travels on a badge, which is where every
269 /// other per-row judgment on this screen already is.
270 ///
271 /// A scheduled task opens the task it stands for, not the event row that
272 /// holds it. `linked_task_id` exists for exactly this: the row's own id is
273 /// the event's, and opening that would address the wrong thing. Moving one
274 /// is a control that steps by a fixed amount, the same shape as the
275 /// milestone row's reorder: a control that is on screen beats a gesture
276 /// nobody discovers. The drag stays refused and the question is closed, not
277 /// open -- quasicoherent `e41079b2` ruled that pre-portioned placement
278 /// replaces it, and placement decides where a block starts while stepping
279 /// is how it moves afterwards.
280 shape row_for(day: &Day, entry: &Entry) -> Row;
281
282 row &entry.item.title {
283 for project in entry.item.project_name.iter() {
284 meta project;
285 }
286
287 token Tag::badge("from earlier").tone(Tone::Neutral) when entry.item.continues_before;
288 token Tag::badge("continues").tone(Tone::Neutral) when entry.item.continues_after;
289 token Tag::badge("clashes").tone(Tone::Danger) when entry.conflicted;
290 token Tag::badge("block").tone(Tone::Info) when entry.item.item_type is "block";
291
292 for task in entry.item.linked_task_id.iter() {
293 activate to get "/tasks/{task}";
294 act "Move earlier" to post "/day/{day.date}/schedule/{task}/move" with "by" "-15";
295 act "Move later" to post "/day/{day.date}/schedule/{task}/move" with "by" "15";
296 act "Unschedule" to post "/day/{day.date}/schedule/{task}/unschedule";
297 }
298 }
299 }
300
301 declare! {
302 /// The axis, and everything placed on it.
303 ///
304 /// The focus is the interesting hour, said as a moment.
305 /// `day-planning-render.js:321` is `const targetHour = 9`, a literal inside
306 /// the renderer; the app is what knows which hour matters and this is where
307 /// it says so.
308 shape timeline(day: &Day) -> Node;
309
310 timeline Track::DAY {
311 focus FOCUS_MINUTES;
312
313 for entry in day.on_axis.iter() {
314 at entry.placement include row_for(day, entry);
315 }
316 }
317 }
318
319 declare! {
320 /// The all-day strip above the axis.
321 ///
322 /// A separate list rather than entries with a full-day placement, for the
323 /// reason `is_all_day` is a field: a bar covering the whole span hides
324 /// everything under it. Absent, not empty, when nothing is all-day.
325 ///
326 /// These are occupancies that happen to fill the column, not contexts. A
327 /// context is drawn in the band as a banner and is not a timeline item at
328 /// all.
329 shape all_day(day: &Day) -> Option<Node>;
330
331 list {
332 for entry in day.covering.iter() {
333 include row_for(day, entry);
334 }
335 } unless day.covering.is_empty();
336 }
337
338 declare! {
339 /// The unscheduled pool.
340 ///
341 /// Everything due today that is not on the axis yet. Each row opens its
342 /// task and carries the control that puts it on the day; see [`placing`]
343 /// for what that asks for and why.
344 ///
345 /// What a task still waits on is a fact about the task, said with a badge
346 /// rather than with a class. Which of the offered tasks is worth scheduling
347 /// first is what the gate cannot say, and only the frees-work half of it
348 /// is drawn: see [`crate::quasi::Availability::frees_marker`].
349 shape pool(day: &Day) -> Node;
350
351 given day.pool.is_empty() {
352 true -> text "Nothing else due today.";
353 otherwise -> list {
354 for waiting in day.pool.iter() {
355 row &waiting.task.title {
356 for project in waiting.task.project_name.iter() {
357 meta project;
358 }
359
360 for gate in waiting.gate.iter() {
361 token Tag::badge(gate).tone(Tone::Warning);
362 }
363
364 token Tag::badge("out of order").tone(Tone::Warning)
365 when waiting.out_of_order;
366
367 for marker in super::Availability::reported(&waiting.task)
368 .frees_marker()
369 .into_iter()
370 {
371 token marker;
372 }
373
374 include placing(&waiting.task, day.date);
375 activate to get "/tasks/{waiting.task.id}";
376 }
377 }
378 }
379 }
380 }
381
382 /// The day before this one, or this one at the edge of the calendar.
383 fn previous(day: &Day) -> NaiveDate {
384 day.date.pred_opt().unwrap_or(day.date)
385 }
386
387 /// The day after.
388 fn next(day: &Day) -> NaiveDate {
389 day.date.succ_opt().unwrap_or(day.date)
390 }
391
392 /// The day, as it is read out.
393 fn title(day: &Day) -> String {
394 day.date.format("%A, %-d %B").to_string()
395 }
396
397 declare! {
398 /// The step-a-day controls and the date, as a band.
399 ///
400 /// What frames the day says so once, at the top, rather than by greying the
401 /// axis. One banner per context: they are states the reader is in rather
402 /// than things on the timeline, so they sit behind the day rather than on
403 /// it. A trip, an illness and a sprint each say what they are.
404 ///
405 /// The way to the screen that authors them sits beside the banners rather
406 /// than on one. A notice can carry an act now, and this is still not one:
407 /// recording a context is most often done for days you are not looking at,
408 /// so the way in has to be there whether or not a banner is.
409 shape band(day: &Day) -> Slot;
410
411 region "day-band" as Band {
412 page title(day);
413
414 chip "Previous" to get "/day/{previous(day)}";
415 chip "Next" to get "/day/{next(day)}";
416
417 for context in day.contexts.iter() {
418 banner Tone::Info context.label.clone();
419 }
420
421 chip "Contexts" to get "/contexts";
422 }
423 }
424
425 declare! {
426 /// The whole day.
427 ///
428 /// `tracked` is `super::time_tracking`'s own region drawn here: today's
429 /// total, and under it the week broken down by project. Nothing else on
430 /// this screen states today's total, since two claims about it would be one
431 /// too many.
432 shape screen(day: &Day, tracked: Slot) -> Screen;
433
434 screen list_detail "Day" false {
435 at_place super::shell::DAY;
436
437 include band(day);
438
439 region "day-timeline" as Pane {
440 for strip in all_day(day).into_iter() {
441 include strip;
442 }
443 include timeline(day);
444 }
445
446 include tracked;
447
448 region "day-pool" as Pane {
449 include pool(day);
450 }
451 }
452 }
453
454 /// The whole day, as an answer.
455 fn day(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
456 let date = date_of(&request)?;
457 let read = read(state, date)?;
458 Ok(screen(
459 &read,
460 super::time_tracking::summary_panel(&super::time_tracking::tracked(state)?),
461 )
462 .into())
463 }
464
465 /// The axis alone, which is what stepping a day replaces.
466 fn timeline_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
467 let date = date_of(&request)?;
468 Ok(Response::fragment(
469 "day-timeline",
470 timeline(&read(state, date)?),
471 ))
472 }
473
474 /// The task a schedule write is addressed at.
475 fn task_of(request: &quasi_router::Request) -> Result<goingson_core::TaskId, RouteError> {
476 let raw = request
477 .captures
478 .get("task")
479 .ok_or_else(|| RouteError::not_found("no task id"))?;
480 Ok(goingson_core::TaskId::from(
481 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
482 ))
483 }
484
485 /// Move a scheduled task by a fixed step, and answer with the axis.
486 ///
487 /// The step is minutes, positive or negative, and the bounds are the shipped
488 /// screen's own: `moveScheduledTask` refuses to leave the day, so a move that
489 /// would cross midnight in either direction is a no-op rather than an error.
490 /// The control that sent it is on screen either way, and the answer to a stale
491 /// screen is a fresh one -- same reasoning as the milestone row's disabled
492 /// arrows.
493 ///
494 /// The write itself is [`crate::commands::day_planning::schedule_task_now`],
495 /// which is the command's own body with the Tauri wrapper taken off. A second
496 /// implementation here would be a second copy of the compensating undo between
497 /// the task write and the linked-event write, and one of the two copies would
498 /// drift.
499 fn move_scheduled(
500 state: &AppState,
501 request: quasi_router::Request,
502 ) -> Result<Response, RouteError> {
503 let date = date_of(&request)?;
504 let id = task_of(&request)?;
505 let by: i64 = request
506 .payload
507 .get("by")
508 .and_then(|raw| raw.parse().ok())
509 .ok_or_else(|| RouteError::not_found("move by a number of minutes"))?;
510
511 let task = state
512 .tasks
513 .get_by_id(id, DESKTOP_USER_ID)
514 .map_err(|error| RouteError::internal(error.to_string()))?
515 .ok_or_else(|| RouteError::not_found("no such task"))?;
516 let Some(start) = task.scheduled_start else {
517 return Err(RouteError::not_found("that task is not on the day"));
518 };
519
520 let moved = start + chrono::Duration::minutes(by);
521 // Inside the same civil day the row is drawn on, which is what stops a
522 // "move later" at 23:45 from silently landing on tomorrow's plan.
523 if moved.with_timezone(&chrono::Local).date_naive() == date {
524 crate::commands::day_planning::schedule_task_now(state, id, moved, task.scheduled_duration)
525 .map_err(|error| RouteError::internal(error.to_string()))?;
526 }
527
528 timeline_fragment(state, date)
529 }
530
531 /// Take a task off the day, and answer with the axis.
532 fn unschedule(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
533 let date = date_of(&request)?;
534 let id = task_of(&request)?;
535 crate::commands::day_planning::unschedule_task_now(state, id)
536 .map_err(|error| RouteError::internal(error.to_string()))?;
537 timeline_fragment(state, date)
538 }
539
540 /// Put a task on the day at a named slot, and answer with the axis.
541 ///
542 /// The slot arrives as minutes from local midnight, which is what [`slots`]
543 /// offers. The length is the task's estimate; a task with none is asked for
544 /// one and **the answer becomes the estimate**, per goingson `fa9fe9ed`. An
545 /// ask that was dismissed sends nothing, so nothing is placed and nothing is
546 /// written. That is the distinction between the ruling and the silent default
547 /// it beat, and it is the easy half to lose.
548 ///
549 /// A slot already occupied is accepted rather than refused. The conflict pass
550 /// reports clashes and the axis draws them in lanes; this screen reports
551 /// rather than prevents, the same as it does for an out-of-order plan.
552 fn place(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
553 let date = date_of(&request)?;
554 let id = task_of(&request)?;
555
556 let at: i32 = request
557 .payload
558 .get("at")
559 .and_then(|raw| raw.parse().ok())
560 .filter(|minutes| (0..DAY_MINUTES).contains(minutes))
561 .ok_or_else(|| RouteError::not_found("place it at a slot of this day"))?;
562
563 let task = state
564 .tasks
565 .get_by_id(id, DESKTOP_USER_ID)
566 .map_err(|error| RouteError::internal(error.to_string()))?
567 .ok_or_else(|| RouteError::not_found("no such task"))?;
568
569 // A stored zero is no estimate: `is_over_estimate` reads it that way and a
570 // block of no length is not a thing the axis can draw.
571 let estimated = task.estimated_minutes.filter(|minutes| *minutes > 0);
572 let minutes = match estimated {
573 Some(estimate) => estimate,
574 None => request
575 .payload
576 .get("minutes")
577 .and_then(|raw| raw.parse().ok())
578 .filter(|asked| (1..=DAY_MINUTES).contains(asked))
579 .ok_or_else(|| RouteError::not_found("say how long it takes"))?,
580 };
581
582 // Placing an unestimated task is what estimates it. Built from the task
583 // just read, so the estimate is the only thing that changes; anything left
584 // out of an `UpdateTask` is cleared, and being put on the day is not a
585 // reason to lose a task's tags.
586 if estimated.is_none() {
587 state
588 .tasks
589 .update(
590 id,
591 DESKTOP_USER_ID,
592 goingson_core::UpdateTask {
593 project_id: task.project_id,
594 milestone_id: task.milestone_id,
595 contact_id: task.contact_id,
596 title: task.title.clone(),
597 description: task.description.clone(),
598 status: task.status.clone(),
599 priority: task.priority.clone(),
600 due: task.due,
601 tags: task.tags.clone(),
602 recurrence: task.recurrence.clone(),
603 recurrence_rule: task.recurrence_rule.clone(),
604 urgency: task.urgency,
605 scheduled_start: task.scheduled_start,
606 scheduled_duration: task.scheduled_duration,
607 estimated_minutes: Some(minutes),
608 },
609 )
610 .map_err(|error| RouteError::internal(error.to_string()))?
611 .ok_or_else(|| RouteError::not_found("no such task"))?;
612 }
613
614 // The slot is a wall-clock time on the day the address names, so the
615 // instant it stands for is a local one. A clock time the local day skips
616 // over is refused rather than nudged: on a spring-forward morning 02:30 is
617 // a slot nobody can start at, and silently placing the block at 03:30
618 // would be this screen answering a question it was not asked.
619 let civil = date
620 .and_hms_opt(
621 u32::try_from(at / 60).unwrap_or(0),
622 u32::try_from(at % 60).unwrap_or(0),
623 0,
624 )
625 .ok_or_else(|| RouteError::not_found("not a time of day"))?;
626 let start = Local
627 .from_local_datetime(&civil)
628 .earliest()
629 .ok_or_else(|| RouteError::conflict("the clock skips that time on this day"))?
630 .with_timezone(&Utc);
631
632 // The command's own body, for [`move_scheduled`]'s reason: the linked
633 // event is written beside the task row, with a compensating undo between
634 // them, and a second copy of that here would drift.
635 crate::commands::day_planning::schedule_task_now(state, id, start, Some(minutes))
636 .map_err(|error| RouteError::internal(error.to_string()))?;
637
638 timeline_fragment(state, date)
639 }
640
641 /// The axis and the pool, re-read. What every write on this screen answers
642 /// with.
643 ///
644 /// Both, because every write here moves a task between them: placing takes one
645 /// out of the pool, unscheduling puts one back, and a move leaves the pool
646 /// alone but is sent through the same door. A fragment that refreshed only the
647 /// axis would leave the pool showing a task that is now on the day, which is
648 /// the screen disagreeing with itself.
649 fn timeline_fragment(state: &AppState, date: NaiveDate) -> Result<Response, RouteError> {
650 let read = read(state, date)?;
651 Ok(Response::fragment("day-timeline", timeline(&read)).also("day-pool", pool(&read)))
652 }
653
654 /// This screen's routes.
655 pub fn routes(router: Router<AppState>) -> Router<AppState> {
656 router
657 .get("/day", day)
658 .get("/day/{date}", day)
659 .get("/day/{date}/timeline", timeline_only)
660 .post("/day/{date}/schedule/{task}/place", place)
661 .post("/day/{date}/schedule/{task}/move", move_scheduled)
662 .post("/day/{date}/schedule/{task}/unschedule", unschedule)
663 }
664