//! Time tracking: the running-timer chrome, and the Timer screen behind it. //! //! //! //! The running-timer band is not part of any screen: it is what the app shows //! while a timer runs, and every screen would otherwise have to repeat it. That //! makes it chrome, and [`Chrome::panel`](quasi_router::Chrome) is what carries //! it. The rest of this module is a screen: the Timer view, its per-project //! report, the focus split, the retroactive log-time control, and the day //! view's tracked-time panel. //! //! # The shape //! //! The chrome: //! //! - [`chrome`] — the panel declaration, held beside the router by //! [`super::protocol`]. //! - `GET /timer/panel` — what the panel holds right now. //! - `POST /timer/start` — begin timing a task, carrying `task`. //! - `POST /timer/stop` — stop the running timer and record the time. //! - `POST /timer/discard` — stop it and record nothing. //! //! The screen: //! //! - `GET /timer` — the Timer screen, under the split and window its address //! carries. //! - `GET /timer/report` — the report alone, which is what changing the window //! replaces. //! - `GET /timer/summary` — the day view's tracked-time panel. //! - `POST /timer/view/track` — start timing a task from the screen. //! - `POST /timer/view/focus` — start a countdown on one, for the split the //! address carries. //! - `POST /timer/view/stop`, `POST /timer/view/discard` — the screen's own //! copies of the panel's two, answering the screen's regions rather than the //! panel's. //! - `POST /timer/view/log` — record time that was never timed. //! //! The screen owns `/timer`; the panel is the smaller thing hanging off it. //! //! # The focus split is an address, not a variable //! //! `work` and `break` ride the address, so a reload lands on the same split and //! a link can name one. Every control the screen draws carries them. //! Out-of-range values are clamped rather than refused: it is a view control, //! and a silly number should show a sane one. `days`, the report's window, //! rides the same address for the same reason. //! //! # Why the log-time modal is not a modal //! //! Minutes and a date, asked for by the row's own Log control before it calls, //! which is [`Act::asking`]. A //! [`RegionKind::Modal`](quasi_router::RegionKind::Modal) would need an address //! per task to open at and a second arrangement for the screen to describe, and //! a [`Node::Form`] loses the verb, because nothing in it says the two boxes //! belong to Log rather than to the screen. //! //! Where the two boxes are drawn is the renderer's: quasi-webview puts them in //! a `
` under the control, a terminal beside it. Both send the same //! values. //! //! # Why the readout is an instant and not a number //! //! The description carries the instant and the renderer carries the words and //! the cadence, so the panel says [`Node::Since`] holding the session's //! `started_at` and stops there. The webview emits `data-clock="since"` and its //! own `clock.js` ticks it; a terminal ticks it on its own clock. //! //! A route that answered a formatted elapsed string would be describing the //! moment it ran, and the readout would freeze at whatever the last request //! made it. [`tests::the_readout_is_an_instant_the_renderer_ticks`] is the //! assertion against it. //! //! # Why the panel asks for itself //! //! A timer starts and stops from places the panel knows nothing about: a task //! row, the drawer, the focus countdown. The panel's region is //! [`Slot::fed_by`] its own route and [`Slot::live`], so it asks what is //! running on the renderer's cadence and nothing else has to remember to tell //! it. The three controls that do know answer with the region re-read, so //! pressing Stop does not wait out a cadence. //! //! The answer re-declares `fed_by` and `live`, and that is load-bearing: the //! swap replaces the element, so a region answered without them is a panel that //! updates once and then never again. //! //! # What this does not carry //! //! **1. A focus session started before `mode` and `ends_at` existed.** //! `ends_at` is `None` there and there is no honest value to invent, so the //! band shows the elapsed time and no countdown. Otherwise the band names the //! mode off the session, and a focus session's readout is [`Node::Until`] //! holding the instant it was started for where a tracked one is //! [`Node::Since`] holding its start. //! //! **2. Withdrawing the panel.** Presence is the app's, and the answer here is //! an empty panel: a mount builds its renderer once and holds it, so the //! declaration cannot come and go per request the way the contents can. The //! panel is declared always and holds nothing while nothing is running, and the //! stylesheet is what keeps an empty band from drawing a bar //! (`.chrome-panel:not(:has([data-clock]))` in `styles.css`). //! //! **3. The full-screen focus overlay.** The row offers Focus, spending the //! split the address carries at the moment it is pressed, and draws no //! full-screen countdown. An overlay is //! [`Outcome::Over`](quasi_router::Outcome::Over), and quasi-webview emits the //! container it retargets at only alongside a [`Chrome`](quasi_router::Chrome) //! binding (`chrome::chrome_html`). This app's chrome is a panel and no //! bindings, so an answer drawn over would be retargeted at an element the //! document does not have. Declaring a binding nothing binds, to get a //! container, is not an answer. Filed as quasicoherent `858be2a6`. //! //! **4. Bar widths.** A description says the proportion and lets the renderer //! draw it, so both bars here are a [`Meter`] of the project's minutes over the //! window's total. A meter's total has to be a set the part is part of, and "of //! the biggest project" is not one. // 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 std::time::{Duration, SystemTime}; use chrono::TimeZone as _; use goingson_core::{ Task, TaskFilterQuery, TaskId, TaskStatus, TimeReport, TimeReportProject, TimeSession, TimeSessionMode, TimeSummaryPanel, }; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Consult, Figure, Meter, Tag}; use quasi_router::{Action, Chrome, Node, Response, Role, RouteError, Router}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The address the chrome panel itself carries. /// /// What a renderer places. Nothing aims an answer here: the contents are the /// region inside it, which is [`BODY`], and the panel keeps the class the /// stylesheet places it by across every swap because the swap never reaches it. pub const PANEL: &str = "timer-panel"; /// The address the panel's contents answer at. /// /// Separate from [`PANEL`] because they are two elements: the band that is /// always there, and what it is holding at the moment. pub const BODY: &str = "timer"; /// The panel, empty, asking for its own contents. /// /// Built once and held by the mount, so the body here is what the document /// carries before the first answer arrives: nothing. The region asks /// immediately (`load`, then the renderer's cadence), so "nothing" lasts one /// round trip. /// /// Takes the chrome and gives it back, the way [`routes`] takes the router. /// [`super::shell`] is what assembles the app's furniture now that the band is /// not the whole of it, and this stays the one answer to what the band says. /// /// `Role::Activity`: the band is about a thing in progress, present while a /// timer runs, rather than about the app's condition. goingson's `Status` one /// is the sync indicator, which is not describable; see [`super::shell`]. #[must_use] pub fn chrome(chrome: Chrome) -> Chrome { chrome.presenting(PANEL, Role::Activity, Node::Region(band())) } declare! { /// The empty band, carrying the address and the call every answer repeats. shape band() -> Slot; region BODY as Band { fed_by Action::get("/timer/panel"); live; } } /// When a session started, as the instant a readout counts from. /// /// `task_list::running_for` does the same conversion for the row readout. Two /// call sites and four lines, so it is spelled twice rather than reached for /// through a module that has nothing else to do with this one. fn started(session: &TimeSession) -> Option { instant(session.started_at) } /// The instant a countdown runs to, if this session is one. fn ending(session: &TimeSession) -> Option { instant(session.ends_at?) } /// A stamp as the clock a renderer ticks against. /// /// `None` only for an instant before the epoch, which nothing in this app can /// write. fn instant(at: chrono::DateTime) -> Option { let seconds = u64::try_from(at.timestamp()).ok()?; Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)) } /// A span of tracked time in words: `1h 5m`, `3h`, or `5m` under the hour. /// /// One spelling for the whole module. The widget and the report each had their /// own in the JS and they disagreed on the exact hour, where `stopActive` says /// `3h 0m` and the report's `fmtMinutes` says `3h`. The report's is the one /// kept: the zero says nothing. fn spans(minutes: i32) -> String { let minutes = minutes.max(0); match (minutes / 60, minutes % 60) { (0, rest) => format!("{rest}m"), (hours, 0) => format!("{hours}h"), (hours, rest) => format!("{hours}h {rest}m"), } } /// What is being timed, if anything. struct Running { session: TimeSession, /// The task the session is against, as the store reads it out. description: String, } /// Read what is running. fn running(state: &AppState) -> Result, RouteError> { Ok(state .tasks .get_active_timer(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .map(|(session, description)| Running { session, description, })) } /// Whether a timer is running at all. fn is_running(running: Option<&Running>) -> bool { running.is_some() } /// What the band calls the mode, off the session rather than out of a variable /// in the renderer's process. /// /// Migration 068 is what makes this sayable: before it, a focus session found /// running after a reload read as Tracking because nothing in the store told /// the two apart. fn mode_label(running: Option<&Running>) -> &'static str { running.map_or("", |running| running.session.mode.label()) } /// The task being timed. fn description(running: Option<&Running>) -> &str { running.map_or("", |running| running.description.as_str()) } /// The instant a countdown runs to, when the band has one to count to. /// /// `None` for a Track session, which has no end, and for a Focus session /// written before migration 068 gave the column somewhere to live. Both answer /// the same way, because a countdown with no end is a countdown a renderer /// cannot draw and the band says the elapsed time instead. fn counting_down(running: Option<&Running>) -> Option { let running = running?; match running.session.mode { TimeSessionMode::Focus => ending(&running.session), TimeSessionMode::Track => None, } } /// When the running session started, as the instant a readout counts from. fn started_at(running: Option<&Running>) -> Option { started(&running?.session) } /// The instant the band counts up from, when it is not counting down. /// /// The whole of the app's half of the readout: a focus session counts down to /// the instant it was started for and a tracked one counts up from its start, /// which is the one difference between the two bands. Both are an instant the /// renderer ticks; neither is a number a route computed. See the module header. /// /// Both are absent for a stamp before the epoch, and the countdown also for a /// focus session predating the column. The band then says the task and no time /// rather than a zero that would read as a timer that has just started. fn counting_up(running: Option<&Running>) -> Option { counting_down(running) .is_none() .then(|| started_at(running))? } declare! { /// What the panel holds: nothing, or the running timer. /// /// The `fed_by` and `live` of [`band`] are repeated here on purpose. See /// the module header: the answer replaces the element, so an answer that /// dropped them would be a panel that stopped asking. /// /// Discarding throws away time that has already been spent, and unlike the /// widget's ghost button there is no undo behind it, so it confirms. The /// same trade the task list's Delete makes. shape contents(running: Option<&Running>) -> Slot; region BODY as Band { fed_by Action::get("/timer/panel"); live; text mode_label(running) when is_running(running); text description(running) when is_running(running); for at in counting_down(running).into_iter() { until at; } for at in counting_up(running).into_iter() { since at; } act "Stop" to post "/timer/stop" when is_running(running) { tone Success; } act "Discard" to post "/timer/discard" when is_running(running) { tone Danger; confirm "Discard the time this timer has tracked?"; } } } /// The panel's contents, as an answer. fn panel(state: &AppState) -> Result { Ok(Response::fragment( BODY, Node::Region(contents(running(state)?.as_ref())), )) } /// What is running, if anything. fn showing(state: &AppState, _request: quasi_router::Request) -> Result { panel(state) } /// The task a start was asked for. fn asked_for(request: &quasi_router::Request) -> Result { let raw = request .payload .get("task") .or_else(|| request.carried.get("task")) .ok_or_else(|| RouteError::not_found("no task id"))?; Ok(TaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, )) } /// Begin timing a task. /// /// The store allows one running timer per user, so starting a second is an /// error rather than a switch. Answered as a complaint on the panel rather than /// as a 500: the user pressed Track on a second task, which is a thing to be /// told about and not a fault. fn start(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; match state.tasks.start_timer(id, DESKTOP_USER_ID) { Ok(_) => Ok(panel(state)?.toast(Tone::Success, "Timer started.")), Err(error) => Ok(panel(state)?.toast(Tone::Warning, error.to_string())), } } /// Stop the running timer and record what it tracked. /// /// Reads what is running rather than being told, which is what `stopActive` /// does through `getActive`: the panel's own control cannot name a task the /// panel is not showing, and being told would let a stale one stop a timer /// started since. fn stop(state: &AppState, _request: quasi_router::Request) -> Result { let running = state .tasks .get_active_timer(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; // Nothing running. The panel is re-read rather than erroring, for the same // reason `stopActive` returns early: the timer stopped somewhere else and // the panel is the thing that is out of date. let Some((session, _)) = running else { return panel(state); }; let stopped = state .tasks .stop_timer(session.task_id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let recorded = stopped .and_then(|session| session.duration_minutes) .unwrap_or(0); Ok(panel(state)?.toast(Tone::Success, format!("Tracked {}", spans(recorded)))) } /// Stop the running timer and record nothing. fn discard(state: &AppState, _request: quasi_router::Request) -> Result { let running = state .tasks .get_active_timer(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let Some((session, _)) = running else { return panel(state); }; state .tasks .discard_timer(session.task_id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(panel(state)?.toast(Tone::Info, "Timer discarded.")) } // The Timer screen. /// The running session, on the screen rather than in the panel. const SESSION: &str = "timer-session"; /// What can be tracked, and the controls that do it. const CHOICES: &str = "timer-choices"; /// The per-project report. const REPORT: &str = "timer-report"; /// The day view's tracked-time panel. /// /// Public because the day view places it: it is this module's region drawn on /// [`super::day_planning`]'s screen, the way `time-summary.js` renders into the /// day sidebar's container. pub const SUMMARY: &str = "time-summary"; /// The focus split's two halves, and the report's window, when the address says /// nothing. `focusWorkMinutes`, `focusBreakMinutes` and `reportDays`. const WORK: i64 = 25; const BREAK: i64 = 5; const DAYS: i64 = 7; /// The windows the report offers. `ranges` in `loadReport`. const WINDOWS: [i64; 3] = [7, 30, 90]; /// How many tasks the screen offers to track. `limit: 200` in `loadTimerView`. const OFFERED: i64 = 200; /// What the Timer screen's address carries. /// /// Three numbers that were three module-scope variables in the JS. See the /// module header: they are the address here, so a reload lands where the user /// left off and every control the screen draws carries them. #[derive(Clone, Copy)] struct View { /// Minutes of work a focus session would be. work: i64, /// Minutes of break after it. `break` is a keyword. rest: i64, /// How many days back the report reads. days: i64, } impl Default for View { fn default() -> Self { Self { work: WORK, rest: BREAK, days: DAYS, } } } impl View { /// The view a request was made under. /// /// Clamped rather than refused, and read from the payload as well as the /// address because a field writes its value into the one and its view into /// the other. fn of(request: &quasi_router::Request) -> Self { let read = |name: &str, fallback: i64, low: i64, high: i64| { request .payload .get(name) .or_else(|| request.carried.get(name)) .and_then(|raw| raw.parse::().ok()) .unwrap_or(fallback) .clamp(low, high) }; Self { work: read("work", WORK, 1, 240), rest: read("break", BREAK, 1, 60), days: read("days", DAYS, 1, 365), } } /// The same control, still pointed at the view it was offered under. fn carry(self, action: Action) -> Action { action .carrying("work", self.work.to_string()) .carrying("break", self.rest.to_string()) .carrying("days", self.days.to_string()) } /// The screen's address as a URL, for the answers that are not a navigation and /// still move the reader. /// /// Written out here because an [`Action`] is not a string until a renderer /// makes it one, and three integers need no escaping. fn url(self) -> String { format!( "/timer?work={}&break={}&days={}", self.work, self.rest, self.days ) } } declare! { /// The two features, named, and the split a focus session would run to. /// /// The cards are the JS's own two paragraphs. They are here for the reason /// it gives: both features write the same session, so without saying so the /// two controls read as two words for one button. /// /// The two numbers are bounds that are a rule rather than a track, which is /// why they are typed rather than [`Field::range`]: a value outside them is /// a thing to be told about rather than a place the control cannot reach. /// `makeover_layout::FieldKind::Range`'s own docs name this exact case. /// /// Each consults the screen carrying every part of the view except its own, /// because a field sends its value under its own name and an address /// carrying it too would answer with the value the user just replaced. shape modes(view: View) -> Slot; region "timer-modes" as Group { section "Track"; text "An open-ended stopwatch. Runs until you stop it, and records the time \ against the task."; section "Focus"; text "A countdown of {view.work} minutes, then a {view.rest} minute break. \ Records the same time."; field Number "work" "Minutes of work" { within "1" "240"; value view.work.to_string(); consulting Consult::new( Action::get("/timer") .carrying("break", view.rest.to_string()) .carrying("days", view.days.to_string()) ); } field Number "break" "Minutes of break" { within "1" "60"; value view.rest.to_string(); consulting Consult::new( Action::get("/timer") .carrying("work", view.work.to_string()) .carrying("days", view.days.to_string()) ); } } } declare! { /// What is running, as the screen's own band. /// /// The panel says the same thing at the bottom of every screen, and this is /// not that region answered twice: the two are separate elements with /// separate addresses, and this one's Stop answers the screen while the /// panel's answers the panel. The panel catches up on its own cadence, /// which is what [`Slot::live`] is for. shape session(running: Option<&Running>, view: View) -> Slot; region SESSION as Band { empty "Nothing is being tracked." unless is_running(running); text description(running) when is_running(running); for at in started_at(running).into_iter() { since at; } act "Stop" to doing view.carry(Action::post("/timer/view/stop")) when is_running(running) { tone Success; } act "Discard" to doing view.carry(Action::post("/timer/view/discard")) when is_running(running) { tone Danger; confirm "Discard the time this timer has tracked?"; } } } /// The tasks a timer can be started on, most likely first. /// /// Started before Pending, which is `loadTimerView`'s order and its reason: a /// task already under way is the one being worked on. The running task is left /// out, because the band above already holds it. fn offered(state: &AppState, running: Option) -> Result, RouteError> { let mut out = Vec::new(); for status in [TaskStatus::Started, TaskStatus::Pending] { let (tasks, _) = state .tasks .list_filtered( DESKTOP_USER_ID, TaskFilterQuery { status: Some(status), project_id: None, milestone_id: None, priority: None, show_snoozed: false, waiting_only: false, offset: Some(0), limit: Some(OFFERED), sort_column: None, sort_direction: None, }, ) .map_err(|error| RouteError::internal(error.to_string()))?; out.extend(tasks.into_iter().filter(|task| Some(task.id) != running)); } Ok(out) } /// What the Timer screen offers to track, and whether anything is in the way. struct Offered { tasks: Vec, /// Whether a timer is already running, which is what disables every control /// below. busy: bool, } /// Read what can be tracked. fn offering(state: &AppState, running: Option<&Running>) -> Result { let held = running.map(|running| running.session.task_id); Ok(Offered { tasks: offered(state, held)?, busy: held.is_some(), }) } /// The row's one trailing fact: the project, and the estimate and the tracked /// total when the task carries them. /// /// One string rather than three settings because `meta` sets rather than /// appends, so three of them left only the last: a task with tracked time never /// showed its project. fn task_meta(task: &Task) -> String { let project = task.project_name.clone(); let estimate = task .estimated_minutes .map(|minutes| format!("{} est", spans(minutes))); let tracked = (task.actual_minutes > 0).then(|| format!("{} tracked", spans(task.actual_minutes))); let said = [project, estimate, tracked] .into_iter() .flatten() .collect::>() .join(" · "); // What the row said when the project was its only fact, kept for the task // that has none of the three. A leading dash in front of an estimate would // be saying "no project" louder than the estimate it sits next to. if said.is_empty() { task.project_name_or_dash().to_owned() } else { said } } /// Today's date, which is what a retroactive log opens on. fn today() -> String { chrono::Local::now().date_naive().to_string() } declare! { /// One task, with what can be done to it. /// /// # What Focus carries that Track does not /// /// The split on the address, spent at the moment it is pressed. The shipped /// row puts the same numbers in the button's `title`; here they are in the /// label, because a description has no hint to put them in and a control /// that does not say what it will do is worse than a long label. /// /// The row offered Track alone until migration 068. What was missing was /// not anything the row could say: the session did not record which feature /// started it, so the two controls would have written the same row and the /// only difference between them would have been the sentence in the toast. /// /// Both are disabled rather than absent while something else is running: /// the store allows one timer per user, so the control is real and /// momentarily refused, and a row that lost its buttons would read as a /// task that cannot be tracked at all. /// /// The project, the estimate and the tracked total are one `meta`, joined by /// [`task_meta`]. `meta` sets rather than appends, so writing them as three /// settings left only the last, which is what the hand-written row did and /// what meant a task with tracked time never showed its project. shape row_for(task: &Task, offered: &Offered, view: View) -> Row; row &task.title { meta task_meta(task); for marker in super::Availability::of(task).marker().into_iter() { token marker; } act "Track" to doing view.carry(Action::post("/timer/view/track")) .with("task", task.id.to_string()) { tone Success; disabled when offered.busy; } act "Focus {view.work}m" to doing view.carry(Action::post("/timer/view/focus")) .with("task", task.id.to_string()) { disabled when offered.busy; } // The log-time modal, as the control that opens it. See the module // header for why it is not a modal here. act "Log" to doing view.carry(Action::post("/timer/view/log")) .with("task", task.id.to_string()) { field Number "minutes" "Minutes" { within "1" "1440"; value "30"; required; } field Date "date" "Date" { value today(); } } } } declare! { /// What a timer can be started on, as a region. shape choices(offered: &Offered, view: View) -> Slot; region CHOICES as Pane { empty "No pending or started tasks to track." when offered.tasks.is_empty(); list { for task in offered.tasks.iter() { include row_for(task, offered, view); } } unless offered.tasks.is_empty(); } } /// Whether the report is already over this window. fn over_window(report: &Report, window: i64) -> bool { window == report.view.days } /// The window a report chip offers. fn windowed_view(view: View, window: i64) -> View { View { days: window, ..view } } /// The report, and the window it was read over. struct Report { read: TimeReport, view: View, /// Everything tracked in the window, which is what each bar is a part of. /// /// Finding 4 in the module header for why this and not the store's /// `bar_percent`: a meter's total has to be a set the part is part of, and /// "of the biggest project" is not one. total: u32, } /// Read the report. fn reported(state: &AppState, view: View) -> Result { let report = crate::commands::time_report(state, Some(view.days)) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Report { total: counted(report.tracked_minutes), read: report, view, }) } /// A count of minutes as a meter reads it. Never negative, never overflowing. fn counted(minutes: i32) -> u32 { u32::try_from(minutes.max(0)).unwrap_or(u32::MAX) } /// What the window came to, said once above the projects. fn window_total(report: &Report) -> String { format!( "{} tracked in the last {} days. Estimates are lifetime totals over tasks that \ carry one.", spans(report.read.tracked_minutes), report.view.days ) } /// Whether anything in this project carries an estimate. /// /// An accuracy of none means nothing does, which is a different statement from /// "estimated zero", and the JS says so outright rather than showing a /// percentage of nothing. fn has_accuracy(project: &TimeReportProject) -> bool { project.estimate_accuracy_percent.is_some() } /// The project row's one trailing fact: what the window tracked, and the /// estimate against the actual when the project carries estimates. /// /// Joined for the same reason [`task_meta`] is: two `meta` settings left only /// the second, so a project with estimates lost its tracked total and the meter /// beside it was the only thing still saying it. fn project_meta(project: &TimeReportProject) -> String { let against = has_accuracy(project).then(|| against_estimate(project)); [Some(spans(project.tracked_minutes)), against] .into_iter() .flatten() .collect::>() .join(" · ") } /// The estimate against the actual, in words. fn against_estimate(project: &TimeReportProject) -> String { format!( "{} est / {} actual", spans(project.estimated_minutes), spans(project.actual_minutes) ) } /// That accuracy, as the badge reads it. fn accuracy(project: &TimeReportProject) -> String { format!("{}%", project.estimate_accuracy_percent.unwrap_or(0)) } /// Whether the project overran its estimates. fn overran(project: &TimeReportProject) -> Tone { if project .estimate_accuracy_percent .is_some_and(|percent| percent > 100) { Tone::Danger } else { Tone::Success } } declare! { /// Where the time went: tracked per project in the window, beside estimated /// against actual for the same projects. /// /// The `fed_by` is re-declared on every answer, the panel's rule and for /// the panel's reason: the swap replaces the element. shape report(report: &Report) -> Slot; region REPORT as Pane { fed_by report.view.carry(Action::get("/timer/report")); section "Where the time went"; for window in WINDOWS { chip "{window}d" to doing windowed_view(report.view, window).carry(Action::get("/timer/report")) { latched when over_window(report, window); } } empty "Nothing tracked or estimated yet." when report.read.projects.is_empty(); text window_total(report) unless report.read.projects.is_empty(); list { for project in report.read.projects.iter() { row &project.name { meta project_meta(project); meter Meter::new(counted(project.tracked_minutes), report.total) .label("minutes"); token Tag::badge(accuracy(project)).tone(overran(project)) when has_accuracy(project); token Tag::badge("no estimates") unless has_accuracy(project); } } } unless report.read.projects.is_empty(); } } /// This week's total across every project, which is what each bar is part of. fn week(panel: &TimeSummaryPanel) -> u32 { counted( panel .projects .iter() .map(|project| project.total_minutes) .sum(), ) } /// Today's total, in words. fn today_total(panel: &TimeSummaryPanel) -> String { spans(panel.today_minutes) } declare! { /// What the day view's tracked-time panel holds. /// /// The collapse the JS wires by hand is a disclosure holding one child, /// open. The name goes on the placement rather than here: this shape answers /// a `Slot`, and a `Slot` cannot carry the name of the control that reveals /// it (quasicoherent `2cdc6761`). See `summary_panel`, which places it with /// `framed`. shape summary_body(panel: &TimeSummaryPanel) -> Slot; region "time-summary-body" as Group { stats [] { figure Figure::new(today_total(panel), "today"); } section "This week" unless panel.projects.is_empty(); list { for project in panel.projects.iter() { row &project.name { meta spans(project.total_minutes); meter Meter::new(counted(project.total_minutes), week(panel)) .label("minutes"); } } } unless panel.projects.is_empty(); } } declare! { /// Today's tracked total and this week's per-project split, as the day view /// draws it. /// /// `time-summary.js`, which is the report's smaller sibling: the same shape /// over a fixed window the app computes rather than one the reader picks. /// /// Public because the day view places it: it is this module's region drawn /// on [`super::day_planning`]'s screen, the way `time-summary.js` renders /// into the day sidebar's container. The read that feeds it is /// [`tracked`], which that screen makes for itself. pub(super) shape summary_panel(panel: &TimeSummaryPanel) -> Slot; region SUMMARY as Group { fed_by Action::get("/timer/summary"); showing_at_most_one Some(0); framed "Time tracked" include summary_body(panel); } } /// What the day view's tracked-time panel is drawn from. pub(super) fn tracked(state: &AppState) -> Result { crate::commands::time_summary_panel(state) .map_err(|error| RouteError::internal(error.to_string())) } declare! { /// The whole screen. shape screen( running: Option<&Running>, offered: &Offered, window: &Report, view: View ) -> Screen; screen list_detail "Timer" false { at_place super::shell::TIMER; region "timer-band" as Band { page "Timer"; } include session(running, view); include modes(view); include choices(offered, view); include report(window); } } /// Everything the Timer screen draws, read once. fn read(state: &AppState, view: View) -> Result<(Option, Offered, Report), RouteError> { let running = running(state)?; let offered = offering(state, running.as_ref())?; let report = reported(state, view)?; Ok((running, offered, report)) } /// The whole screen, as an answer. fn view(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let (running, offered, window) = read(state, view)?; Ok(screen(running.as_ref(), &offered, &window, view).into()) } /// The three regions a write on this screen moves, together. /// /// Together because they move together: starting a timer fills the band and /// disables every Track control, and logging time changes both the row's /// tracked total and the report under it. fn answer(state: &AppState, view: View) -> Result { let (running, offered, window) = read(state, view)?; Ok( Response::fragment(SESSION, Node::Region(session(running.as_ref(), view))) .also(CHOICES, Node::Region(choices(&offered, view))) .also(REPORT, Node::Region(report(&window))), ) } /// The report alone, which is what changing the window replaces. /// /// Answered with the screen's address rather than the region's, so a reload /// lands on the same window. The window is a fact about the view and the region /// is where it happens to be drawn. fn windowed(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); Ok(Response::fragment(REPORT, Node::Region(report(&reported(state, view)?))).at(view.url())) } /// The day view's panel, as an answer. fn summarised(state: &AppState, _request: quasi_router::Request) -> Result { Ok(Response::fragment( SUMMARY, Node::Region(summary_panel(&tracked(state)?)), )) } /// Start timing a task from the screen. /// /// The panel's [`start`] refused a second timer with a complaint rather than a /// fault, and this does the same for the same reason. fn track(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let view = View::of(&request); match state.tasks.start_timer(id, DESKTOP_USER_ID) { Ok(_) => Ok(answer(state, view)?.toast(Tone::Success, "Timer started.")), Err(error) => Ok(answer(state, view)?.toast(Tone::Warning, error.to_string())), } } /// Start a focus countdown on a task, from the screen. /// /// The split it runs to is the one the address carries at the moment it is /// pressed, which is what "carrying the split it was started under" means: the /// session records the instant, so changing the split afterwards moves the next /// countdown rather than this one. /// /// The full-screen overlay `focus-timer.js` draws is still not described, and /// that is quasicoherent `858be2a6` rather than this route: an overlay is /// [`Outcome::Over`](quasi_router::Outcome::Over), and quasi-webview emits the /// container it retargets at only alongside a chrome binding. What this app /// draws instead is the band, which counts down to the same instant on every /// screen. That is less than the shipped overlay and it is not a toast with a /// different sentence in it. fn focus(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let view = View::of(&request); let ends_at = chrono::Utc::now() + chrono::Duration::minutes(view.work); match state .tasks .start_focus_session(id, DESKTOP_USER_ID, ends_at) { Ok(_) => Ok(answer(state, view)?.toast( Tone::Success, format!("Focus session started, {} minutes.", view.work), )), Err(error) => Ok(answer(state, view)?.toast(Tone::Warning, error.to_string())), } } /// Stop the running timer, from the screen. fn halt(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let running = state .tasks .get_active_timer(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let Some((session, _)) = running else { return answer(state, view); }; let stopped = state .tasks .stop_timer(session.task_id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let recorded = stopped .and_then(|session| session.duration_minutes) .unwrap_or(0); Ok(answer(state, view)?.toast(Tone::Success, format!("Tracked {}", spans(recorded)))) } /// Throw the running timer away, from the screen. fn drop_it(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let running = state .tasks .get_active_timer(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let Some((session, _)) = running else { return answer(state, view); }; state .tasks .discard_timer(session.task_id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(answer(state, view)?.toast(Tone::Info, "Timer discarded.")) } /// Record time that was never timed. /// /// The date is a day and the session wants an instant, so the day becomes noon /// UTC, which is `submitLogTime`'s own conversion and its reason: a day with no /// time in it lands in the same date bucket whichever side of UTC the reader is /// on. A missing date is today, which is what the shipped field opens on. fn log(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let view = View::of(&request); let minutes = request .payload .get("minutes") .and_then(|raw| raw.parse::().ok()) .ok_or_else(|| RouteError::not_found("no duration"))?; let minutes = goingson_core::PositiveMinutes::try_new(minutes) .map_err(|error| RouteError::not_found(error.to_string()))?; let day = match request.payload.get("date") { Some(raw) => chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") .map_err(|_| RouteError::not_found("not a date"))?, None => chrono::Local::now().date_naive(), }; let at = chrono::Utc.from_utc_datetime( &day.and_hms_opt(12, 0, 0) .ok_or_else(|| RouteError::internal("noon is always valid"))?, ); let session = state .tasks .log_manual_time(id, DESKTOP_USER_ID, minutes, at) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(answer(state, view)?.toast( Tone::Success, format!("Logged {}", spans(session.duration_minutes.unwrap_or(0))), )) } /// The panel's routes, and the screen's. pub fn routes(router: Router) -> Router { router .get("/timer", view) .get("/timer/panel", showing) .get("/timer/report", windowed) .get("/timer/summary", summarised) .post("/timer/start", start) .post("/timer/stop", stop) .post("/timer/discard", discard) .post("/timer/view/track", track) .post("/timer/view/focus", focus) .post("/timer/view/stop", halt) .post("/timer/view/discard", drop_it) .post("/timer/view/log", log) }