//! The day view, described rather than built. //! //! //! //! A timeline is [`Track`](makeover_layout::Track): where a thing starts and //! how long it lasts, which is two integers rather than a component library. //! //! # The shape //! //! - `GET /day` — today. //! - `GET /day/{date}` — one day, `YYYY-MM-DD`. //! - `GET /day/{date}/timeline` — the axis alone, which is what stepping //! between days replaces. //! //! # What is drawn here, and what the host keeps //! //! Drawn here: the axis, everything on it, the conflict tone, the all-day //! strip, the unscheduled pool and the vacation banner. //! //! Not here, and each a decision rather than an omission: //! //! - **Drag to reschedule.** Moving an existing item is a continuous gesture //! and the vocabulary names no such thing. Stepping by a fixed amount is //! what replaced it, on a row's own controls. //! - **Drag to paint a new block.** Placement replaced it: a task is put on //! the day at a named slot, at the size its estimate gives it. Both //! quasicoherent `e41079b2` and goingson `fa9fe9ed` are settled, and neither //! asked for a gesture. See [`placing`]. //! //! Nothing here is waiting on a continuous-input member. The vocabulary the //! screen needed was [`Placed`] to draw a block and [`Act::asking`] to ask for //! one before writing it, and both existed. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's. #![allow(clippy::needless_pass_by_value)] use chrono::{Local, NaiveDate, TimeZone, Utc}; use goingson_core::TimelineItem; use makeover_layout::{Placement, Tone, Track}; use quasi_declare::declare; use quasi_router::screen::{Choice, Tag}; use quasi_router::{Response, RouteError, Router, Slot}; use crate::commands::{ContextResponse, DayPlanningResponse, TaskResponse, day_plan}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The day a route was addressed at, or today. /// /// A missing capture is `/day`, which is today. An unparseable one is a 404 /// rather than a silent fallback to today: `/day/yesterday` is an address that /// names nothing, and answering it with today's plan would be this screen /// deciding it knows better than the URL. fn date_of(request: &quasi_router::Request) -> Result { let Some(raw) = request.captures.get("date") else { return Ok(Local::now().date_naive()); }; NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|_| RouteError::not_found("not a date")) } /// The plan, or an internal error. fn plan(state: &AppState, date: NaiveDate) -> Result { day_plan(state, date).map_err(|error| RouteError::internal(error.to_string())) } /// The hour the axis opens on, and the slot a placement is offered at first. /// /// One number for both, because they answer the same question: which part of /// the day the reader is most likely to mean. const FOCUS_MINUTES: u16 = 9 * 60; /// The grid a placement lands on, in minutes. /// /// The same 15 the move controls step by and the same 96 slots the ruler /// draws. Shared deliberately: a block placed off the grid could not be /// stepped back onto it. const SLOT_MINUTES: i32 = 15; /// A day, in minutes. The bound on both a slot and a duration. const DAY_MINUTES: i32 = 24 * 60; /// The length offered for a task nobody has estimated. /// /// One slot, the smallest honest guess. It is a pre-filled answer and not a /// silent default: dismissing the ask places nothing and writes nothing, which /// is the whole of what goingson `fa9fe9ed` ruled. const UNESTIMATED_MINUTES: i32 = 15; /// Every slot of the day, as the choices a placement picks between. /// /// The argument is discrete and this is what makes it so. A wall-clock field /// would let the answer name another date, which the address already carries, /// and would then have to be checked against it. fn slots() -> Vec { (0..DAY_MINUTES / SLOT_MINUTES) .map(|slot| { let minutes = slot * SLOT_MINUTES; Choice::new( minutes.to_string(), format!("{:02}:{:02}", minutes / 60, minutes % 60), ) }) .collect() } /// Whether the task already says how long it takes. /// /// A stored zero is no estimate: `is_over_estimate` reads it that way and a /// block of no length is not a thing the axis can draw. fn estimated(task: &TaskResponse) -> bool { task.estimated_minutes.is_some_and(|minutes| minutes > 0) } declare! { /// Putting a task on the day, as the control that does it. /// /// A task is placed at its own size: the block is as long as the estimate, /// so the day shows the work rather than a row of identical stubs. A task /// with no estimate is placeable anyway, and **placing it is what sets the /// estimate**. goingson `fa9fe9ed` ruled (d) over defaulting silently, over /// refusing the placement, and over drawing a guess differently. The /// planner is the one place a person is already thinking about how long the /// thing takes, so it is the right place to be asked. /// /// Both halves are [`Act::asking`]: a control that asks for a value before /// it acts. The second question is asked only when there is nothing to ask /// it about instead, so a task that carries an estimate is placed in one /// answer rather than being asked to confirm what it already says. shape placing(task: &TaskResponse, date: NaiveDate) -> Act; act "Place on the day" to post "/day/{date}/schedule/{task.id}/place" { field Select "at" "Start at" { options slots(); value FOCUS_MINUTES.to_string(); required; } field Number "minutes" "How long it takes" unless estimated(task) { within "1" DAY_MINUTES.to_string(); value UNESTIMATED_MINUTES.to_string(); hint "Placing it records this as the task's estimate."; required; } } } /// One thing on the day, and the two facts the plan worked out for it. struct Entry { item: TimelineItem, /// Whether it covers any of the same time as something else. conflicted: bool, /// Where it sits on the axis, and for how long. /// /// Both already computed by the command, in the units [`Placement`] wants. /// Nothing was added to the backend for this, which is the sign that what /// the vocabulary was missing was the ability to *say* it. placement: Placement, } /// One unscheduled task, with what the plan says about it. struct Waiting { task: TaskResponse, /// What it still waits on, said in words. gate: Option, /// Whether the plan puts it out of order. out_of_order: bool, } /// Everything the day view draws, read once. struct Day { date: NaiveDate, /// What sits on the axis, with where it sits. on_axis: Vec, /// What covers the whole day, drawn above the axis. covering: Vec, /// Everything due today that is not on the axis yet. pool: Vec, /// The states framing the day. contexts: Vec, } /// Read the day. /// /// The conflict pairs the backend computes become a set, because a row needs to /// know only whether it is in one and `detect_conflicts` answers in pairs. fn read(state: &AppState, date: NaiveDate) -> Result { let response = plan(state, date)?; let clashing: std::collections::HashSet<_> = response .conflicts .iter() .flat_map(|conflict| [conflict.item1_id, conflict.item2_id]) .collect(); let (all_day, on_axis): (Vec, Vec) = response .timeline_items .into_iter() // An item covering the whole column is not on the axis: a bar over all // 24 hours papers over every real appointment. That is geometry and // nothing more; a context is its own record and never a timeline item. .partition(|item| item.is_all_day); let placed = |item: TimelineItem, conflicted: bool| Entry { placement: Placement::new( u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0), u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1), ), conflicted, item, }; let pool = response .unscheduled_tasks .into_iter() .map(|task| { let gate = response.gates.get(&task.id); Waiting { gate: gate.and_then(|gate| { gate.after.first().map(|first| { if gate.after.len() > 1 { format!("after {} +{}", first.title, gate.after.len() - 1) } else { format!("after {}", first.title) } }) }), out_of_order: gate.is_some_and(|gate| gate.out_of_order), task, } }) .collect(); Ok(Day { date, on_axis: on_axis .into_iter() .map(|item| { let conflicted = clashing.contains(&item.id); placed(item, conflicted) }) .collect(), covering: all_day .into_iter() .map(|item| placed(item, false)) .collect(), pool, contexts: response.contexts, }) } declare! { /// One item as a row, without its placement. /// /// The whole body is vocabulary that existed before this screen: a title, /// the project it belongs to, a tone, and a chip when the item continues /// past the edge of the day. That is the measurement the timeline refusal /// never took. /// /// An event that runs over midnight is one row and two bars, and the bar /// this day draws is clipped to this day. The chips say the clipping /// happened, in words rather than with a drawing. /// /// A focus block is a different kind of thing from an appointment. A row /// carries no tone of its own -- deliberately, since a whole line in a /// colour is a slab -- so the fact travels on a badge, which is where every /// other per-row judgment on this screen already is. /// /// A scheduled task opens the task it stands for, not the event row that /// holds it. `linked_task_id` exists for exactly this: the row's own id is /// the event's, and opening that would address the wrong thing. Moving one /// is a control that steps by a fixed amount, the same shape as the /// milestone row's reorder: a control that is on screen beats a gesture /// nobody discovers. The drag stays refused and the question is closed, not /// open -- quasicoherent `e41079b2` ruled that pre-portioned placement /// replaces it, and placement decides where a block starts while stepping /// is how it moves afterwards. shape row_for(day: &Day, entry: &Entry) -> Row; row &entry.item.title { for project in entry.item.project_name.iter() { meta project; } token Tag::badge("from earlier").tone(Tone::Neutral) when entry.item.continues_before; token Tag::badge("continues").tone(Tone::Neutral) when entry.item.continues_after; token Tag::badge("clashes").tone(Tone::Danger) when entry.conflicted; token Tag::badge("block").tone(Tone::Info) when entry.item.item_type is "block"; for task in entry.item.linked_task_id.iter() { activate to get "/tasks/{task}"; act "Move earlier" to post "/day/{day.date}/schedule/{task}/move" with "by" "-15"; act "Move later" to post "/day/{day.date}/schedule/{task}/move" with "by" "15"; act "Unschedule" to post "/day/{day.date}/schedule/{task}/unschedule"; } } } declare! { /// The axis, and everything placed on it. /// /// The focus is the interesting hour, said as a moment. /// `day-planning-render.js:321` is `const targetHour = 9`, a literal inside /// the renderer; the app is what knows which hour matters and this is where /// it says so. shape timeline(day: &Day) -> Node; timeline Track::DAY { focus FOCUS_MINUTES; for entry in day.on_axis.iter() { at entry.placement include row_for(day, entry); } } } declare! { /// The all-day strip above the axis. /// /// A separate list rather than entries with a full-day placement, for the /// reason `is_all_day` is a field: a bar covering the whole span hides /// everything under it. Absent, not empty, when nothing is all-day. /// /// These are occupancies that happen to fill the column, not contexts. A /// context is drawn in the band as a banner and is not a timeline item at /// all. shape all_day(day: &Day) -> Option; list { for entry in day.covering.iter() { include row_for(day, entry); } } unless day.covering.is_empty(); } declare! { /// The unscheduled pool. /// /// Everything due today that is not on the axis yet. Each row opens its /// task and carries the control that puts it on the day; see [`placing`] /// for what that asks for and why. /// /// What a task still waits on is a fact about the task, said with a badge /// rather than with a class. Which of the offered tasks is worth scheduling /// first is what the gate cannot say, and only the frees-work half of it /// is drawn: see [`crate::quasi::Availability::frees_marker`]. shape pool(day: &Day) -> Node; given day.pool.is_empty() { true -> text "Nothing else due today."; otherwise -> list { for waiting in day.pool.iter() { row &waiting.task.title { for project in waiting.task.project_name.iter() { meta project; } for gate in waiting.gate.iter() { token Tag::badge(gate).tone(Tone::Warning); } token Tag::badge("out of order").tone(Tone::Warning) when waiting.out_of_order; for marker in super::Availability::reported(&waiting.task) .frees_marker() .into_iter() { token marker; } include placing(&waiting.task, day.date); activate to get "/tasks/{waiting.task.id}"; } } } } } /// The day before this one, or this one at the edge of the calendar. fn previous(day: &Day) -> NaiveDate { day.date.pred_opt().unwrap_or(day.date) } /// The day after. fn next(day: &Day) -> NaiveDate { day.date.succ_opt().unwrap_or(day.date) } /// The day, as it is read out. fn title(day: &Day) -> String { day.date.format("%A, %-d %B").to_string() } declare! { /// The step-a-day controls and the date, as a band. /// /// What frames the day says so once, at the top, rather than by greying the /// axis. One banner per context: they are states the reader is in rather /// than things on the timeline, so they sit behind the day rather than on /// it. A trip, an illness and a sprint each say what they are. /// /// The way to the screen that authors them sits beside the banners rather /// than on one. A notice can carry an act now, and this is still not one: /// recording a context is most often done for days you are not looking at, /// so the way in has to be there whether or not a banner is. shape band(day: &Day) -> Slot; region "day-band" as Band { page title(day); chip "Previous" to get "/day/{previous(day)}"; chip "Next" to get "/day/{next(day)}"; for context in day.contexts.iter() { banner Tone::Info context.label.clone(); } chip "Contexts" to get "/contexts"; } } declare! { /// The whole day. /// /// `tracked` is `super::time_tracking`'s own region drawn here: today's /// total, and under it the week broken down by project. Nothing else on /// this screen states today's total, since two claims about it would be one /// too many. shape screen(day: &Day, tracked: Slot) -> Screen; screen list_detail "Day" false { at_place super::shell::DAY; include band(day); region "day-timeline" as Pane { for strip in all_day(day).into_iter() { include strip; } include timeline(day); } include tracked; region "day-pool" as Pane { include pool(day); } } } /// The whole day, as an answer. fn day(state: &AppState, request: quasi_router::Request) -> Result { let date = date_of(&request)?; let read = read(state, date)?; Ok(screen( &read, super::time_tracking::summary_panel(&super::time_tracking::tracked(state)?), ) .into()) } /// The axis alone, which is what stepping a day replaces. fn timeline_only(state: &AppState, request: quasi_router::Request) -> Result { let date = date_of(&request)?; Ok(Response::fragment( "day-timeline", timeline(&read(state, date)?), )) } /// The task a schedule write is addressed at. fn task_of(request: &quasi_router::Request) -> Result { let raw = request .captures .get("task") .ok_or_else(|| RouteError::not_found("no task id"))?; Ok(goingson_core::TaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, )) } /// Move a scheduled task by a fixed step, and answer with the axis. /// /// The step is minutes, positive or negative, and the bounds are the shipped /// screen's own: `moveScheduledTask` refuses to leave the day, so a move that /// would cross midnight in either direction is a no-op rather than an error. /// The control that sent it is on screen either way, and the answer to a stale /// screen is a fresh one -- same reasoning as the milestone row's disabled /// arrows. /// /// The write itself is [`crate::commands::day_planning::schedule_task_now`], /// which is the command's own body with the Tauri wrapper taken off. A second /// implementation here would be a second copy of the compensating undo between /// the task write and the linked-event write, and one of the two copies would /// drift. fn move_scheduled( state: &AppState, request: quasi_router::Request, ) -> Result { let date = date_of(&request)?; let id = task_of(&request)?; let by: i64 = request .payload .get("by") .and_then(|raw| raw.parse().ok()) .ok_or_else(|| RouteError::not_found("move by a number of minutes"))?; let task = state .tasks .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; let Some(start) = task.scheduled_start else { return Err(RouteError::not_found("that task is not on the day")); }; let moved = start + chrono::Duration::minutes(by); // Inside the same civil day the row is drawn on, which is what stops a // "move later" at 23:45 from silently landing on tomorrow's plan. if moved.with_timezone(&chrono::Local).date_naive() == date { crate::commands::day_planning::schedule_task_now(state, id, moved, task.scheduled_duration) .map_err(|error| RouteError::internal(error.to_string()))?; } timeline_fragment(state, date) } /// Take a task off the day, and answer with the axis. fn unschedule(state: &AppState, request: quasi_router::Request) -> Result { let date = date_of(&request)?; let id = task_of(&request)?; crate::commands::day_planning::unschedule_task_now(state, id) .map_err(|error| RouteError::internal(error.to_string()))?; timeline_fragment(state, date) } /// Put a task on the day at a named slot, and answer with the axis. /// /// The slot arrives as minutes from local midnight, which is what [`slots`] /// offers. The length is the task's estimate; a task with none is asked for /// one and **the answer becomes the estimate**, per goingson `fa9fe9ed`. An /// ask that was dismissed sends nothing, so nothing is placed and nothing is /// written. That is the distinction between the ruling and the silent default /// it beat, and it is the easy half to lose. /// /// A slot already occupied is accepted rather than refused. The conflict pass /// reports clashes and the axis draws them in lanes; this screen reports /// rather than prevents, the same as it does for an out-of-order plan. fn place(state: &AppState, request: quasi_router::Request) -> Result { let date = date_of(&request)?; let id = task_of(&request)?; let at: i32 = request .payload .get("at") .and_then(|raw| raw.parse().ok()) .filter(|minutes| (0..DAY_MINUTES).contains(minutes)) .ok_or_else(|| RouteError::not_found("place it at a slot of this day"))?; let task = state .tasks .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; // A stored zero is no estimate: `is_over_estimate` reads it that way and a // block of no length is not a thing the axis can draw. let estimated = task.estimated_minutes.filter(|minutes| *minutes > 0); let minutes = match estimated { Some(estimate) => estimate, None => request .payload .get("minutes") .and_then(|raw| raw.parse().ok()) .filter(|asked| (1..=DAY_MINUTES).contains(asked)) .ok_or_else(|| RouteError::not_found("say how long it takes"))?, }; // Placing an unestimated task is what estimates it. Built from the task // just read, so the estimate is the only thing that changes; anything left // out of an `UpdateTask` is cleared, and being put on the day is not a // reason to lose a task's tags. if estimated.is_none() { state .tasks .update( id, DESKTOP_USER_ID, goingson_core::UpdateTask { project_id: task.project_id, milestone_id: task.milestone_id, contact_id: task.contact_id, title: task.title.clone(), description: task.description.clone(), status: task.status.clone(), priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: task.recurrence.clone(), recurrence_rule: task.recurrence_rule.clone(), urgency: task.urgency, scheduled_start: task.scheduled_start, scheduled_duration: task.scheduled_duration, estimated_minutes: Some(minutes), }, ) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; } // The slot is a wall-clock time on the day the address names, so the // instant it stands for is a local one. A clock time the local day skips // over is refused rather than nudged: on a spring-forward morning 02:30 is // a slot nobody can start at, and silently placing the block at 03:30 // would be this screen answering a question it was not asked. let civil = date .and_hms_opt( u32::try_from(at / 60).unwrap_or(0), u32::try_from(at % 60).unwrap_or(0), 0, ) .ok_or_else(|| RouteError::not_found("not a time of day"))?; let start = Local .from_local_datetime(&civil) .earliest() .ok_or_else(|| RouteError::conflict("the clock skips that time on this day"))? .with_timezone(&Utc); // The command's own body, for [`move_scheduled`]'s reason: the linked // event is written beside the task row, with a compensating undo between // them, and a second copy of that here would drift. crate::commands::day_planning::schedule_task_now(state, id, start, Some(minutes)) .map_err(|error| RouteError::internal(error.to_string()))?; timeline_fragment(state, date) } /// The axis and the pool, re-read. What every write on this screen answers /// with. /// /// Both, because every write here moves a task between them: placing takes one /// out of the pool, unscheduling puts one back, and a move leaves the pool /// alone but is sent through the same door. A fragment that refreshed only the /// axis would leave the pool showing a task that is now on the day, which is /// the screen disagreeing with itself. fn timeline_fragment(state: &AppState, date: NaiveDate) -> Result { let read = read(state, date)?; Ok(Response::fragment("day-timeline", timeline(&read)).also("day-pool", pool(&read))) } /// This screen's routes. pub fn routes(router: Router) -> Router { router .get("/day", day) .get("/day/{date}", day) .get("/day/{date}/timeline", timeline_only) .post("/day/{date}/schedule/{task}/place", place) .post("/day/{date}/schedule/{task}/move", move_scheduled) .post("/day/{date}/schedule/{task}/unschedule", unschedule) }