max / goingson
6 files changed,
+675 insertions,
-4 deletions
| @@ -622,10 +622,17 @@ | |||
| 622 | 622 | /// and the check is one-sided: if the real figure is lower it warns and asks | |
| 623 | 623 | /// for the seal to come down, which is the direction it is meant to move. | |
| 624 | 624 | /// | |
| 625 | + | /// # 39 to 38, 2026-08-20: the arithmetic above, measured | |
| 626 | + | /// | |
| 627 | + | /// The pin moved, the graph resolves, and the build script counts 38 rather | |
| 628 | + | /// than the 39 predicted by hand. One of the two markdown classes is written | |
| 629 | + | /// somewhere this app's markup already reaches. Lowered so it cannot grow back, | |
| 630 | + | /// which is what the check asked for on every build in between. | |
| 631 | + | /// | |
| 625 | 632 | /// The one direction this number should move is down, so if it ever needs | |
| 626 | 633 | /// raising for a class goingson's own markup *should* be writing, that is the | |
| 627 | 634 | /// defect this exists to catch and the answer is the markup, not the seal. | |
| 628 | - | const DEAD_VOCABULARY_HIGH_WATER: usize = 39; | |
| 635 | + | const DEAD_VOCABULARY_HIGH_WATER: usize = 38; | |
| 629 | 636 | ||
| 630 | 637 | /// Every file that can carry a class name. | |
| 631 | 638 | fn markup_files(frontend: &Path) -> Vec<PathBuf> { |
| @@ -9714,3 +9714,38 @@ | |||
| 9714 | 9714 | background: var(--warning); | |
| 9715 | 9715 | color: var(--content); | |
| 9716 | 9716 | } | |
| 9717 | + | ||
| 9718 | + | /* 63. Chrome Panel (the described side's running-timer band) */ | |
| 9719 | + | ||
| 9720 | + | /* Where the app's persistent panel sits. quasi's description declines to say, | |
| 9721 | + | because a terminal has no floating; a browser has a stylesheet, and this is | |
| 9722 | + | it. Same furniture as .timer-widget above, which is the JS widget this | |
| 9723 | + | replaces once the quasi feature is on. | |
| 9724 | + | ||
| 9725 | + | Hidden while it holds no clock. The panel element is emitted on every | |
| 9726 | + | document because a mount builds its renderer once and cannot withdraw the | |
| 9727 | + | declaration per answer, so "nothing is running" arrives as an empty band, and | |
| 9728 | + | an empty band must not draw a bar across the bottom of the screen. The clock | |
| 9729 | + | is the marker because it is what the running state is made of: no timer, no | |
| 9730 | + | readout, no panel. */ | |
| 9731 | + | .chrome-panel { | |
| 9732 | + | position: fixed; | |
| 9733 | + | bottom: 0; | |
| 9734 | + | left: 0; | |
| 9735 | + | right: 0; | |
| 9736 | + | z-index: 900; | |
| 9737 | + | background: var(--surface-page); | |
| 9738 | + | border-top: calc(var(--border-width) * 3) solid var(--action); | |
| 9739 | + | box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); | |
| 9740 | + | padding: var(--gap-peer) var(--gap-group); | |
| 9741 | + | } | |
| 9742 | + | ||
| 9743 | + | .chrome-panel:not(:has([data-clock])) { | |
| 9744 | + | display: none; | |
| 9745 | + | } | |
| 9746 | + | ||
| 9747 | + | .chrome-panel [data-clock] { | |
| 9748 | + | font-family: var(--font-mono, monospace); | |
| 9749 | + | font-size: var(--font-size-xl); | |
| 9750 | + | font-variant-numeric: tabular-nums; | |
| 9751 | + | } |
| @@ -58,10 +58,17 @@ | |||
| 58 | 58 | //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 | | |
| 59 | 59 | //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 | | |
| 60 | 60 | //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 | | |
| 61 | + | //! | [`time_tracking`] | `time-tracking.js`, the floating widget half | 0 | | |
| 61 | 62 | //! | |
| 62 | 63 | //! [`projects`] carries its dashboard as a submodule, which is the thirteenth | |
| 63 | 64 | //! described screen against twelve modules here. | |
| 64 | 65 | //! | |
| 66 | + | //! [`time_tracking`] is the one row here that is not a screen: it is the app's | |
| 67 | + | //! chrome, described because the widget it replaces belongs to no screen | |
| 68 | + | //! either. Its counterpart file therefore does not retire on its row alone, | |
| 69 | + | //! which is why the file is still in the un-ported list below with its six | |
| 70 | + | //! sites intact. | |
| 71 | + | //! | |
| 65 | 72 | //! `settings.js` went 6 to 11 on 2026-08-19, and gained `notifs.js` beside it | |
| 66 | 73 | //! (no sites of its own). Both are the notification kinds reaching the shipped | |
| 67 | 74 | //! screen: [`settings`] generates that pane from the registry and the shipped | |
| @@ -98,8 +105,11 @@ | |||
| 98 | 105 | //! - `events.js` 10. Its grid rendering is bespoke; whether the CRUD/list half | |
| 99 | 106 | //! is a real candidate is unchecked, and per this table that is a claim to | |
| 100 | 107 | //! measure rather than assert. | |
| 101 | - | //! - `time-tracking.js` 6, `time-summary.js` 3. Waits on quasicoherent | |
| 102 | - | //! `f00244a6`, who owns a ticking clock. | |
| 108 | + | //! - `time-tracking.js` 6, `time-summary.js` 3. The floating widget half is | |
| 109 | + | //! described now and is [`time_tracking`]'s row above; it carries none of | |
| 110 | + | //! these six, because the widget writes through `textContent` and it is the | |
| 111 | + | //! Timer sub-view and the report that concatenate. Those two are what is | |
| 112 | + | //! left, and the file does not retire until they are described. | |
| 103 | 113 | //! - `task-graph.js` 6. Draws the blocking DAG. | |
| 104 | 114 | //! | |
| 105 | 115 | //! ## Not a screen (10 files, 27 sites) | |
| @@ -181,6 +191,7 @@ | |||
| 181 | 191 | pub mod settings; | |
| 182 | 192 | pub mod task_list; | |
| 183 | 193 | pub mod tasks; | |
| 194 | + | pub mod time_tracking; | |
| 184 | 195 | pub mod weekly_review; | |
| 185 | 196 | ||
| 186 | 197 | /// Where a task sits in the dependency graph, for the surfaces that draw it. | |
| @@ -384,6 +395,7 @@ | |||
| 384 | 395 | let router = board::routes(router); | |
| 385 | 396 | let router = task_list::routes(router); | |
| 386 | 397 | let router = data::routes(router); | |
| 398 | + | let router = time_tracking::routes(router); | |
| 387 | 399 | emails::routes(router) | |
| 388 | 400 | } | |
| 389 | 401 | ||
| @@ -400,7 +412,11 @@ | |||
| 400 | 412 | Arc::new( | |
| 401 | 413 | quasi_webview::Webview::under("quasi://localhost/static").with_shell( | |
| 402 | 414 | quasi_webview::Shell::under("quasi://localhost/static") | |
| 403 | - | .styled("/static/styles.css"), | |
| 415 | + | .styled("/static/styles.css") | |
| 416 | + | // The running-timer band, which belongs to the app rather | |
| 417 | + | // than to any screen it is drawn over. See | |
| 418 | + | // [`time_tracking`]. | |
| 419 | + | .with_chrome(time_tracking::chrome()), | |
| 404 | 420 | ), | |
| 405 | 421 | ), | |
| 406 | 422 | ) |
| @@ -469,6 +469,18 @@ | |||
| 469 | 469 | if !sessions.is_empty() { | |
| 470 | 470 | out.push(Node::list(sessions.iter().map(session_row))); | |
| 471 | 471 | } | |
| 472 | + | ||
| 473 | + | // Track Time, which the shipped drawer offers as a modal button and which | |
| 474 | + | // here is the one control that starts a timer anywhere in the described | |
| 475 | + | // app. Stopping is not offered beside it: a running timer is the chrome | |
| 476 | + | // panel's, and a second Stop here would be a second answer to what stopping | |
| 477 | + | // means. See [`super::time_tracking`]. | |
| 478 | + | if !task.has_active_timer() { | |
| 479 | + | out.push(Node::Act(Act::new( | |
| 480 | + | "Track time", | |
| 481 | + | Action::post("/timer/start").with("task", task.id.to_string()), | |
| 482 | + | ))); | |
| 483 | + | } | |
| 472 | 484 | out | |
| 473 | 485 | } | |
| 474 | 486 |
| @@ -1,0 +1,272 @@ | |||
| 1 | + | //! The running-timer widget, described as chrome rather than as a screen. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! `time-tracking.js` builds a floating bar at the bottom of the document, | |
| 6 | + | //! appends it to `body` once at startup, and keeps it there whatever view the | |
| 7 | + | //! user is on. It is not part of any screen: it is what the app shows while a | |
| 8 | + | //! timer runs, and every screen would otherwise have to repeat it. | |
| 9 | + | //! | |
| 10 | + | //! Max ruled on 2026-08-20 that such a thing is chrome, and quasicoherent | |
| 11 | + | //! `46fc763c` grew [`Chrome::panel`](quasi_router::Chrome) for it. This module | |
| 12 | + | //! is the consumer. | |
| 13 | + | //! | |
| 14 | + | //! # The shape | |
| 15 | + | //! | |
| 16 | + | //! - [`chrome`] — the panel declaration, held beside the router by | |
| 17 | + | //! [`super::protocol`]. | |
| 18 | + | //! - `GET /timer` — what the panel holds right now. | |
| 19 | + | //! - `POST /timer/start` — begin timing a task, carrying `task`. | |
| 20 | + | //! - `POST /timer/stop` — stop the running timer and record the time. | |
| 21 | + | //! - `POST /timer/discard` — stop it and record nothing. | |
| 22 | + | //! | |
| 23 | + | //! # Why the readout is an instant and not a number | |
| 24 | + | //! | |
| 25 | + | //! `updateElapsed` subtracts `Date.now()` from the start time every second, in | |
| 26 | + | //! JavaScript, against an element the same file built. Under quasicoherent | |
| 27 | + | //! `f00244a6` the description carries the instant and the renderer carries the | |
| 28 | + | //! words and the cadence, so the panel says [`Node::Since`] holding the | |
| 29 | + | //! session's `started_at` and stops there. That is what makes the readout | |
| 30 | + | //! advance after the JS is gone: the webview emits `data-clock="since"` and its | |
| 31 | + | //! own `clock.js` ticks it, the terminal ticks it on its own clock, and neither | |
| 32 | + | //! of them is this app's problem. | |
| 33 | + | //! | |
| 34 | + | //! A route that answered a formatted elapsed string would be describing the | |
| 35 | + | //! moment it ran, and the readout would freeze at whatever the last request | |
| 36 | + | //! made it. That is the failure this ordering exists to avoid, and | |
| 37 | + | //! [`tests::the_readout_is_an_instant_the_renderer_ticks`] is the assertion | |
| 38 | + | //! against it. | |
| 39 | + | //! | |
| 40 | + | //! # Why the panel asks for itself | |
| 41 | + | //! | |
| 42 | + | //! A timer starts and stops from places the panel knows nothing about: a task | |
| 43 | + | //! row, the drawer, the focus countdown. The JS answered that by calling | |
| 44 | + | //! `checkActive()` from each of them. Here the panel's region is | |
| 45 | + | //! [`Slot::fed_by`] its own route and [`Slot::live`], so it asks what is running | |
| 46 | + | //! on the renderer's cadence and nothing else has to remember to tell it. The | |
| 47 | + | //! three controls that do know answer with the region re-read, so pressing Stop | |
| 48 | + | //! does not wait out a cadence. | |
| 49 | + | //! | |
| 50 | + | //! The answer re-declares `fed_by` and `live`, which is load-bearing rather than | |
| 51 | + | //! incidental: the swap replaces the element, so a region answered without them | |
| 52 | + | //! is a panel that updates once and then never again. | |
| 53 | + | //! | |
| 54 | + | //! # Two things the port does not carry | |
| 55 | + | //! | |
| 56 | + | //! **1. The mode label.** The widget says "Tracking" or "Focus session", and | |
| 57 | + | //! `activeMode` is a variable in the JS process. Both features write the same | |
| 58 | + | //! session, so the store cannot tell them apart, and the file says so itself: | |
| 59 | + | //! a timer found running after a reload reads as Tracking because the countdown | |
| 60 | + | //! "lives in this process and does not survive one". Nothing the app can read | |
| 61 | + | //! carries the fact, so the panel does not say it. Making it sayable is a | |
| 62 | + | //! column on the session, not a member of the vocabulary. | |
| 63 | + | //! | |
| 64 | + | //! **2. Withdrawing the panel.** The ruling leaves presence to the app, and the | |
| 65 | + | //! app's answer here is an empty panel: a mount builds its renderer once and | |
| 66 | + | //! holds it, so the declaration cannot come and go per request the way the | |
| 67 | + | //! contents can. The panel is declared always and holds nothing while nothing is | |
| 68 | + | //! running, and the stylesheet is what keeps an empty band from drawing a bar | |
| 69 | + | //! (`.chrome-panel:not(:has([data-clock]))` in `styles.css`, which is the same | |
| 70 | + | //! placement question the renderer already answers with CSS). A renderer that | |
| 71 | + | //! could be handed a fresh chrome per answer would not need that rule, and that | |
| 72 | + | //! is a quasi finding rather than a goingson one. | |
| 73 | + | ||
| 74 | + | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 75 | + | // plain `fn(&S, Request)` pointer, so the signature is the router's. | |
| 76 | + | #![allow(clippy::needless_pass_by_value)] | |
| 77 | + | ||
| 78 | + | use std::time::{Duration, SystemTime}; | |
| 79 | + | ||
| 80 | + | use goingson_core::{TaskId, TimeSession}; | |
| 81 | + | use makeover_layout::Tone; | |
| 82 | + | use quasi_router::screen::Act; | |
| 83 | + | use quasi_router::{Action, Chrome, Node, RegionKind, Response, RouteError, Router, Slot}; | |
| 84 | + | ||
| 85 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 86 | + | ||
| 87 | + | #[cfg(test)] | |
| 88 | + | mod tests; | |
| 89 | + | ||
| 90 | + | /// The address the chrome panel itself carries. | |
| 91 | + | /// | |
| 92 | + | /// What a renderer places. Nothing aims an answer here: the contents are the | |
| 93 | + | /// region inside it, which is [`BODY`], and the panel keeps the class the | |
| 94 | + | /// stylesheet places it by across every swap because the swap never reaches it. | |
| 95 | + | pub const PANEL: &str = "timer-panel"; | |
| 96 | + | ||
| 97 | + | /// The address the panel's contents answer at. | |
| 98 | + | /// | |
| 99 | + | /// Separate from [`PANEL`] because they are two elements: the band that is | |
| 100 | + | /// always there, and what it is holding at the moment. | |
| 101 | + | pub const BODY: &str = "timer"; | |
| 102 | + | ||
| 103 | + | /// The panel, empty, asking for its own contents. | |
| 104 | + | /// | |
| 105 | + | /// Built once and held by the mount, so the body here is what the document | |
| 106 | + | /// carries before the first answer arrives: nothing. The region asks | |
| 107 | + | /// immediately (`load`, then the renderer's cadence), so "nothing" lasts one | |
| 108 | + | /// round trip. | |
| 109 | + | #[must_use] | |
| 110 | + | pub fn chrome() -> Chrome { | |
| 111 | + | Chrome::new().presenting(PANEL, Node::Region(band())) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// The empty band, carrying the address and the call every answer repeats. | |
| 115 | + | fn band() -> Slot { | |
| 116 | + | Slot::new(BODY, RegionKind::Band) | |
| 117 | + | .fed_by(Action::get("/timer")) | |
| 118 | + | .live() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// When a session started, as the instant a readout counts from. | |
| 122 | + | /// | |
| 123 | + | /// `task_list::running_for` does the same conversion for the row readout. Two | |
| 124 | + | /// call sites and four lines, so it is spelled twice rather than reached for | |
| 125 | + | /// through a module that has nothing else to do with this one. | |
| 126 | + | fn started(session: &TimeSession) -> Option<SystemTime> { | |
| 127 | + | let seconds = u64::try_from(session.started_at.timestamp()).ok()?; | |
| 128 | + | Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// A tracked span in the widget's own words: `1h 5m`, or `5m` under the hour. | |
| 132 | + | fn tracked(minutes: i32) -> String { | |
| 133 | + | let minutes = minutes.max(0); | |
| 134 | + | if minutes >= 60 { | |
| 135 | + | format!("{}h {}m", minutes / 60, minutes % 60) | |
| 136 | + | } else { | |
| 137 | + | format!("{minutes}m") | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// What the panel holds: nothing, or the running timer. | |
| 142 | + | /// | |
| 143 | + | /// The `fed_by` and `live` of [`band`] are repeated here on purpose. See the | |
| 144 | + | /// module header: the answer replaces the element, so an answer that dropped | |
| 145 | + | /// them would be a panel that stopped asking. | |
| 146 | + | fn contents(state: &AppState) -> Result<Slot, RouteError> { | |
| 147 | + | let running = state | |
| 148 | + | .tasks | |
| 149 | + | .get_active_timer(DESKTOP_USER_ID) | |
| 150 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 151 | + | ||
| 152 | + | let Some((session, description)) = running else { | |
| 153 | + | return Ok(band()); | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | let mut slot = band().with(Node::text(description)); | |
| 157 | + | ||
| 158 | + | // The whole of the app's half of the readout. `started` is `None` only for | |
| 159 | + | // a session stamped before the epoch, which nothing in this app can write; | |
| 160 | + | // the band says the task and no time rather than a zero that would read as | |
| 161 | + | // a timer that has just started. | |
| 162 | + | if let Some(at) = started(&session) { | |
| 163 | + | slot = slot.with(Node::Since { at }); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | Ok(slot | |
| 167 | + | .with(Node::Act( | |
| 168 | + | Act::new("Stop", Action::post("/timer/stop")).tone(Tone::Success), | |
| 169 | + | )) | |
| 170 | + | // Discarding throws away time that has already been spent, and unlike | |
| 171 | + | // the widget's ghost button there is no undo behind it, so it confirms. | |
| 172 | + | // The same trade the task list's Delete makes. | |
| 173 | + | .with(Node::Act( | |
| 174 | + | Act::new("Discard", Action::post("/timer/discard")) | |
| 175 | + | .tone(Tone::Danger) | |
| 176 | + | .confirm("Discard the time this timer has tracked?"), | |
| 177 | + | ))) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// The panel's contents, as an answer. | |
| 181 | + | fn panel(state: &AppState) -> Result<Response, RouteError> { | |
| 182 | + | Ok(Response::fragment(BODY, Node::Region(contents(state)?))) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// What is running, if anything. | |
| 186 | + | fn showing(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 187 | + | panel(state) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// The task a start was asked for. | |
| 191 | + | fn asked_for(request: &quasi_router::Request) -> Result<TaskId, RouteError> { | |
| 192 | + | let raw = request | |
| 193 | + | .payload | |
| 194 | + | .get("task") | |
| 195 | + | .or_else(|| request.carried.get("task")) | |
| 196 | + | .ok_or_else(|| RouteError::not_found("no task id"))?; | |
| 197 | + | Ok(TaskId::from( | |
| 198 | + | uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, | |
| 199 | + | )) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Begin timing a task. | |
| 203 | + | /// | |
| 204 | + | /// The store allows one running timer per user, so starting a second is an | |
| 205 | + | /// error rather than a switch. Answered as a complaint on the panel rather than | |
| 206 | + | /// as a 500: the user pressed Track on a second task, which is a thing to be | |
| 207 | + | /// told about and not a fault. | |
| 208 | + | fn start(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 209 | + | let id = asked_for(&request)?; | |
| 210 | + | match state.tasks.start_timer(id, DESKTOP_USER_ID) { | |
| 211 | + | Ok(_) => Ok(panel(state)?.toast(Tone::Success, "Timer started.")), | |
| 212 | + | Err(error) => Ok(panel(state)?.toast(Tone::Warning, error.to_string())), | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Stop the running timer and record what it tracked. | |
| 217 | + | /// | |
| 218 | + | /// Reads what is running rather than being told, which is what `stopActive` | |
| 219 | + | /// does through `getActive`: the panel's own control cannot name a task the | |
| 220 | + | /// panel is not showing, and being told would let a stale one stop a timer | |
| 221 | + | /// started since. | |
| 222 | + | fn stop(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 223 | + | let running = state | |
| 224 | + | .tasks | |
| 225 | + | .get_active_timer(DESKTOP_USER_ID) | |
| 226 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 227 | + | ||
| 228 | + | // Nothing running. The panel is re-read rather than erroring, for the same | |
| 229 | + | // reason `stopActive` returns early: the timer stopped somewhere else and | |
| 230 | + | // the panel is the thing that is out of date. | |
| 231 | + | let Some((session, _)) = running else { | |
| 232 | + | return panel(state); | |
| 233 | + | }; | |
| 234 | + | ||
| 235 | + | let stopped = state | |
| 236 | + | .tasks | |
| 237 | + | .stop_timer(session.task_id, DESKTOP_USER_ID) | |
| 238 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 239 | + | ||
| 240 | + | let recorded = stopped | |
| 241 | + | .and_then(|session| session.duration_minutes) | |
| 242 | + | .unwrap_or(0); | |
| 243 | + | Ok(panel(state)?.toast(Tone::Success, format!("Tracked {}", tracked(recorded)))) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Stop the running timer and record nothing. | |
| 247 | + | fn discard(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 248 | + | let running = state | |
| 249 | + | .tasks | |
| 250 | + | .get_active_timer(DESKTOP_USER_ID) | |
| 251 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 252 | + | ||
| 253 | + | let Some((session, _)) = running else { | |
| 254 | + | return panel(state); | |
| 255 | + | }; | |
| 256 | + | ||
| 257 | + | state | |
| 258 | + | .tasks | |
| 259 | + | .discard_timer(session.task_id, DESKTOP_USER_ID) | |
| 260 | + | .map_err(|error| RouteError::internal(error.to_string()))?; | |
| 261 | + | ||
| 262 | + | Ok(panel(state)?.toast(Tone::Info, "Timer discarded.")) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// The panel's routes. | |
| 266 | + | pub fn routes(router: Router<AppState>) -> Router<AppState> { | |
| 267 | + | router | |
| 268 | + | .get("/timer", showing) | |
| 269 | + | .post("/timer/start", start) | |
| 270 | + | .post("/timer/stop", stop) | |
| 271 | + | .post("/timer/discard", discard) | |
| 272 | + | } |
| @@ -1,0 +1,329 @@ | |||
| 1 | + | //! The chrome panel, driven through the router against a real database. | |
| 2 | + | //! | |
| 3 | + | //! The assertions worth reading are the two about *movement*. A panel drawn | |
| 4 | + | //! once and never again is the whole failure this port can have: the JS | |
| 5 | + | //! interval is gone, so if the readout is a string the route formatted, or if | |
| 6 | + | //! the answered region drops the call that fetched it, the timer freezes and | |
| 7 | + | //! every other assertion here still passes. So one test renders the same | |
| 8 | + | //! running timer twice a second apart and demands the readouts differ, and one | |
| 9 | + | //! reads the answer's own markup for the call that brings the next one. | |
| 10 | + | ||
| 11 | + | use std::sync::Arc; | |
| 12 | + | ||
| 13 | + | use goingson_core::{NewTask, Priority, TaskId}; | |
| 14 | + | use quasi_http::Serves as _; | |
| 15 | + | use quasi_router::{Outcome, Params, Request, Response}; | |
| 16 | + | ||
| 17 | + | use super::super::router; | |
| 18 | + | use super::{BODY, PANEL}; | |
| 19 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 20 | + | ||
| 21 | + | async fn state() -> Arc<AppState> { | |
| 22 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 23 | + | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 24 | + | state | |
| 25 | + | .db | |
| 26 | + | .conn() | |
| 27 | + | .unwrap() | |
| 28 | + | .execute( | |
| 29 | + | "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ | |
| 30 | + | VALUES (?, ?, ?, ?, ?)", | |
| 31 | + | rusqlite::params![ | |
| 32 | + | DESKTOP_USER_ID.to_string(), | |
| 33 | + | "desktop@localhost", | |
| 34 | + | "x", | |
| 35 | + | "Desktop User", | |
| 36 | + | &now, | |
| 37 | + | ], | |
| 38 | + | ) | |
| 39 | + | .unwrap(); | |
| 40 | + | state | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | fn task(state: &AppState, title: &str) -> TaskId { | |
| 44 | + | state | |
| 45 | + | .tasks | |
| 46 | + | .create( | |
| 47 | + | DESKTOP_USER_ID, | |
| 48 | + | NewTask::builder(title).priority(Priority::Medium).build(), | |
| 49 | + | ) | |
| 50 | + | .unwrap() | |
| 51 | + | .id | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn html(response: &Response) -> String { | |
| 55 | + | match &response.outcome { | |
| 56 | + | Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen), | |
| 57 | + | Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node), | |
| 58 | + | Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), | |
| 59 | + | Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn get(state: &AppState, path: &str) -> Response { | |
| 64 | + | router() | |
| 65 | + | .handle(state, Request::get(path).carrying(Params::new())) | |
| 66 | + | .expect("the route answers") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn post(state: &AppState, path: &str, params: Params) -> Response { | |
| 70 | + | router() | |
| 71 | + | .handle(state, Request::post(path).sending(params)) | |
| 72 | + | .expect("the route answers") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | fn panel(state: &AppState) -> String { | |
| 76 | + | html(&get(state, "/timer")) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// The words inside the readout, or `None` when the panel is not showing one. | |
| 80 | + | fn readout(markup: &str) -> Option<String> { | |
| 81 | + | let at = markup.find("data-clock=\"since\"")?; | |
| 82 | + | let opens = markup[at..].find('>')? + at + 1; | |
| 83 | + | let closes = markup[opens..].find('<')? + opens; | |
| 84 | + | Some(markup[opens..closes].to_owned()) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[tokio::test] | |
| 88 | + | async fn the_panel_is_chrome_rather_than_a_region_every_screen_repeats() { | |
| 89 | + | // Max, 2026-08-20. The point of the ruling: no screen mentions the timer, | |
| 90 | + | // and every screen shows it. | |
| 91 | + | let chrome = super::chrome(); | |
| 92 | + | let declared = chrome.panel.as_ref().expect("a panel is declared"); | |
| 93 | + | assert_eq!(declared.id, PANEL); | |
| 94 | + | ||
| 95 | + | let state = state().await; | |
| 96 | + | let id = task(&state, "Write the thing"); | |
| 97 | + | state | |
| 98 | + | .tasks | |
| 99 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 100 | + | .expect("the timer starts"); | |
| 101 | + | ||
| 102 | + | // A screen that knows nothing about timers, drawn through a renderer | |
| 103 | + | // carrying this chrome. | |
| 104 | + | let board = get(&state, "/board"); | |
| 105 | + | let Outcome::Screen(screen) = &board.outcome else { | |
| 106 | + | panic!("the board answers a screen"); | |
| 107 | + | }; | |
| 108 | + | let with_chrome = quasi_webview::Webview::new() | |
| 109 | + | .with_shell(quasi_webview::Shell::default().with_chrome(super::chrome())) | |
| 110 | + | .screen(screen); | |
| 111 | + | assert!( | |
| 112 | + | with_chrome.contains(&format!("id=\"{PANEL}\"")), | |
| 113 | + | "{with_chrome}" | |
| 114 | + | ); | |
| 115 | + | assert!(with_chrome.contains("chrome-panel"), "{with_chrome}"); | |
| 116 | + | ||
| 117 | + | // And an app that declares none draws what it drew before, which is what | |
| 118 | + | // makes the panel additive rather than a change to every screen. | |
| 119 | + | let bare = quasi_webview::Webview::new().screen(screen); | |
| 120 | + | assert!(!bare.contains("chrome-panel"), "{bare}"); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[tokio::test] | |
| 124 | + | async fn nothing_running_is_an_empty_panel_rather_than_a_bar_saying_so() { | |
| 125 | + | let state = state().await; | |
| 126 | + | let markup = panel(&state); | |
| 127 | + | // No readout, which is what the stylesheet hides the band by. A panel that | |
| 128 | + | // said "No timer running" would be furniture across the bottom of every | |
| 129 | + | // screen all day. | |
| 130 | + | assert!(readout(&markup).is_none(), "{markup}"); | |
| 131 | + | assert!(!markup.contains("Stop"), "{markup}"); | |
| 132 | + | assert!(!markup.contains("Discard"), "{markup}"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[tokio::test] | |
| 136 | + | async fn a_running_timer_names_the_task_and_offers_stop_and_discard() { | |
| 137 | + | let state = state().await; | |
| 138 | + | let id = task(&state, "Write the thing"); | |
| 139 | + | state | |
| 140 | + | .tasks | |
| 141 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 142 | + | .expect("the timer starts"); | |
| 143 | + | ||
| 144 | + | let markup = panel(&state); | |
| 145 | + | assert!(markup.contains("Write the thing"), "{markup}"); | |
| 146 | + | assert!(markup.contains("/timer/stop"), "{markup}"); | |
| 147 | + | assert!(markup.contains("/timer/discard"), "{markup}"); | |
| 148 | + | // Discarding throws away time already spent and has no undo behind it. | |
| 149 | + | assert!(markup.contains("Discard the time"), "{markup}"); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[tokio::test] | |
| 153 | + | async fn the_readout_is_an_instant_the_renderer_ticks() { | |
| 154 | + | // Not a formatted elapsed string. `f00244a6`: the app carries the instant, | |
| 155 | + | // the renderer carries the words and the cadence. | |
| 156 | + | let state = state().await; | |
| 157 | + | let id = task(&state, "Write the thing"); | |
| 158 | + | let session = state | |
| 159 | + | .tasks | |
| 160 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 161 | + | .expect("the timer starts"); | |
| 162 | + | ||
| 163 | + | let markup = panel(&state); | |
| 164 | + | assert!(markup.contains("data-clock=\"since\""), "{markup}"); | |
| 165 | + | let at = u128::try_from(session.started_at.timestamp()).expect("after the epoch") * 1000; | |
| 166 | + | assert!(markup.contains(&format!("data-at=\"{at}\"")), "{markup}"); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[tokio::test] | |
| 170 | + | async fn the_readout_advances() { | |
| 171 | + | // The one that matters. Every other assertion here passes just as well | |
| 172 | + | // against a timer that renders once and stops, which is what deleting a JS | |
| 173 | + | // interval without wiring the renderer's tick produces. | |
| 174 | + | let state = state().await; | |
| 175 | + | let id = task(&state, "Write the thing"); | |
| 176 | + | state | |
| 177 | + | .tasks | |
| 178 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 179 | + | .expect("the timer starts"); | |
| 180 | + | ||
| 181 | + | let first = readout(&panel(&state)).expect("a readout"); | |
| 182 | + | // The renderer's granularity is a second, so a second is what it takes to | |
| 183 | + | // observe one pass. | |
| 184 | + | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; | |
| 185 | + | let second = readout(&panel(&state)).expect("a readout"); | |
| 186 | + | ||
| 187 | + | assert_ne!( | |
| 188 | + | first, second, | |
| 189 | + | "the readout did not move between two renders a second apart" | |
| 190 | + | ); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[tokio::test] | |
| 194 | + | async fn the_panel_keeps_asking_after_it_has_been_answered() { | |
| 195 | + | // The other half of advancing, and the half a swap silently removes: the | |
| 196 | + | // answer replaces the element, so an answer that dropped the call would be | |
| 197 | + | // a panel that updated once. Both the declaration and the answer carry it. | |
| 198 | + | let state = state().await; | |
| 199 | + | let declared = super::chrome(); | |
| 200 | + | let quasi_router::Node::Region(slot) = &declared.panel.expect("declared").content else { | |
| 201 | + | panic!("the panel holds a region"); | |
| 202 | + | }; | |
| 203 | + | assert_eq!(slot.id, BODY); | |
| 204 | + | assert!(slot.live); | |
| 205 | + | assert!(slot.fed_by.is_some()); | |
| 206 | + | ||
| 207 | + | let markup = panel(&state); | |
| 208 | + | assert!(markup.contains("hx-get=\"/timer\""), "{markup}"); | |
| 209 | + | assert!(markup.contains("hx-trigger=\"load, every"), "{markup}"); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tokio::test] | |
| 213 | + | async fn stopping_records_the_time_and_leaves_the_panel_empty() { | |
| 214 | + | let state = state().await; | |
| 215 | + | let id = task(&state, "Write the thing"); | |
| 216 | + | state | |
| 217 | + | .tasks | |
| 218 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 219 | + | .expect("the timer starts"); | |
| 220 | + | ||
| 221 | + | let stopped = post(&state, "/timer/stop", Params::new()); | |
| 222 | + | let notice = stopped.notice.as_ref().expect("it says what it recorded"); | |
| 223 | + | assert!(notice.text.starts_with("Tracked "), "{}", notice.text); | |
| 224 | + | ||
| 225 | + | let markup = html(&stopped); | |
| 226 | + | assert!(readout(&markup).is_none(), "{markup}"); | |
| 227 | + | assert!( | |
| 228 | + | state | |
| 229 | + | .tasks | |
| 230 | + | .get_active_timer(DESKTOP_USER_ID) | |
| 231 | + | .expect("read") | |
| 232 | + | .is_none() | |
| 233 | + | ); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[tokio::test] | |
| 237 | + | async fn discarding_leaves_the_panel_empty_and_records_nothing() { | |
| 238 | + | let state = state().await; | |
| 239 | + | let id = task(&state, "Write the thing"); | |
| 240 | + | state | |
| 241 | + | .tasks | |
| 242 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 243 | + | .expect("the timer starts"); | |
| 244 | + | ||
| 245 | + | let discarded = post(&state, "/timer/discard", Params::new()); | |
| 246 | + | let markup = html(&discarded); | |
| 247 | + | assert!(readout(&markup).is_none(), "{markup}"); | |
| 248 | + | ||
| 249 | + | let sessions = state | |
| 250 | + | .tasks | |
| 251 | + | .list_time_sessions(id, DESKTOP_USER_ID) | |
| 252 | + | .expect("read"); | |
| 253 | + | assert!(sessions.is_empty(), "{sessions:?}"); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[tokio::test] | |
| 257 | + | async fn stopping_what_is_no_longer_running_answers_the_panel_rather_than_failing() { | |
| 258 | + | // The timer was stopped somewhere else and the panel is the thing out of | |
| 259 | + | // date, which is exactly what `stopActive`'s early return handles. | |
| 260 | + | let state = state().await; | |
| 261 | + | let stopped = post(&state, "/timer/stop", Params::new()); | |
| 262 | + | assert!(readout(&html(&stopped)).is_none()); | |
| 263 | + | let discarded = post(&state, "/timer/discard", Params::new()); | |
| 264 | + | assert!(readout(&html(&discarded)).is_none()); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[tokio::test] | |
| 268 | + | async fn starting_a_second_timer_is_a_complaint_and_leaves_the_first_running() { | |
| 269 | + | let state = state().await; | |
| 270 | + | let first = task(&state, "Write the thing"); | |
| 271 | + | let second = task(&state, "Write the other thing"); | |
| 272 | + | ||
| 273 | + | let started = post( | |
| 274 | + | &state, | |
| 275 | + | "/timer/start", | |
| 276 | + | Params::new().with("task", first.to_string()), | |
| 277 | + | ); | |
| 278 | + | assert!(readout(&html(&started)).is_some()); | |
| 279 | + | ||
| 280 | + | let refused = post( | |
| 281 | + | &state, | |
| 282 | + | "/timer/start", | |
| 283 | + | Params::new().with("task", second.to_string()), | |
| 284 | + | ); | |
| 285 | + | let notice = refused.notice.as_ref().expect("it says why"); | |
| 286 | + | assert_eq!(notice.tone, makeover_layout::Tone::Warning); | |
| 287 | + | ||
| 288 | + | let markup = html(&refused); | |
| 289 | + | assert!(markup.contains("Write the thing"), "{markup}"); | |
| 290 | + | assert!(!markup.contains("Write the other thing"), "{markup}"); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | #[tokio::test] | |
| 294 | + | async fn a_start_aimed_at_nothing_is_refused_rather_than_guessed_at() { | |
| 295 | + | let state = state().await; | |
| 296 | + | assert!( | |
| 297 | + | router() | |
| 298 | + | .handle( | |
| 299 | + | &state, | |
| 300 | + | Request::post("/timer/start").sending(Params::new().with("task", "not-a-uuid")), | |
| 301 | + | ) | |
| 302 | + | .is_err() | |
| 303 | + | ); | |
| 304 | + | assert!( | |
| 305 | + | router() | |
| 306 | + | .handle(&state, Request::post("/timer/start").sending(Params::new())) | |
| 307 | + | .is_err() | |
| 308 | + | ); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[tokio::test] | |
| 312 | + | async fn the_drawer_is_where_a_timer_starts_and_stops_offering_it_while_one_runs() { | |
| 313 | + | // The panel offers Stop and Discard and nothing that starts anything, so | |
| 314 | + | // without this the described app can watch a timer it cannot begin. | |
| 315 | + | let state = state().await; | |
| 316 | + | let id = task(&state, "Write the thing"); | |
| 317 | + | ||
| 318 | + | let before = html(&get(&state, &format!("/tasks/{id}"))); | |
| 319 | + | assert!(before.contains("Track time"), "{before}"); | |
| 320 | + | assert!(before.contains("/timer/start"), "{before}"); | |
| 321 | + | ||
| 322 | + | state | |
| 323 | + | .tasks | |
| 324 | + | .start_timer(id, DESKTOP_USER_ID) | |
| 325 | + | .expect("the timer starts"); | |
| 326 | + | ||
| 327 | + | let during = html(&get(&state, &format!("/tasks/{id}"))); | |
| 328 | + | assert!(!during.contains("Track time"), "{during}"); | |
| 329 | + | } |