Skip to main content

max / goingson

Describe the running-timer widget as chrome Max ruled on 2026-08-20 that an always-present floating panel belongs to the app rather than to any screen it is drawn over, and quasicoherent 46fc763c grew Chrome::panel for it. This is the consumer. quasi/time_tracking declares the panel and answers its contents: the task being timed, the elapsed readout, Stop and Discard. The readout is Node::Since holding the session's started_at, so the words, the format and the tick are the renderer's, which is the whole of what time-tracking.js's per-second subtraction was doing by hand. The panel's region is fed_by its own route and live, so it asks what is running on the renderer's cadence and nothing has to remember to tell it. Every answer re-declares both, which is load-bearing: the swap replaces the element, so an answer that dropped them would be a panel that updated once and then froze. Two tests hold that down. One renders the same running timer twice a second apart and demands the readouts differ; one reads the answer's own markup for the call that brings the next one. The task drawer offers Track time, since the panel offers Stop and Discard and nothing that starts anything, and the described app could otherwise watch a timer it could not begin. Two things the port does not carry, both written down beside the code. The mode label is a variable in the JS process and no column carries it, so the panel does not say whether a countdown or a stopwatch is running. And a mount builds its renderer once, so the declaration cannot come and go per request the way the contents can: the panel is declared always and holds nothing while nothing runs, and .chrome-panel:not(:has([data-clock])) is what keeps an empty band from drawing a bar. time-tracking.js is not deleted. It also holds the Timer sub-view, the report, the focus split and the log-time modal, and eight other files call into it; the widget half is what is described here, and the flip is what retires the file. Also lowers the dead-vocabulary seal to the 38 the build script has been measuring since the makeover pin moved.
Author: Max Johnson <me@maxj.phd> · 2026-08-20 17:56 UTC
Signed with PGP, not checked
Commit: 3ea4bd5e1c5bd05ff6aa054a8a500a6b6d99ef9a
Parent: d4cde8c
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 + }