Skip to main content

max / goingson

Describe the rest of time tracking: the Timer screen 3af69f4c described the floating widget as chrome and found the widget is about a third of time-tracking.js. The rest is a screen, and this is it: the Timer sub-view, the per-project report, the focus split and the retroactive log-time modal, plus time-summary.js, the same report's smaller sibling in the day view's sidebar. The screen takes /timer and the panel moves to /timer/panel. Screens own the plain addresses everywhere else in this module tree. The focus split and the report window are the address rather than three module-scope JS variables, per the rule the monthly review's month settled, so a reload lands where the reader left off and every control the screen draws carries them. Out-of-range values are clamped, which is what updateFocusSplit does and for the same reason. The log-time modal is the row's Log control asking for a duration and a day before it calls, which is Act::asking. A contained modal would need an address per task and a second arrangement for the screen to describe, the refusal task_list already recorded for Edit, and a form loses the verb. Where the two boxes are drawn is the renderer's. The day view places the tracked-time panel, which is this module's region: it said one line reading "Tracked today" until now, a thin version of what time-summary.js renders into that sidebar. Two things are deliberately left out rather than papered over, both recorded in the module header with the tasks filed for them. The Focus control and its countdown: no column says which feature started a session or when a countdown ends, so a countdown computed from the address would draw over every tracked timer too (goingson 44cfcac7), and this app declares no chrome bindings, so quasi-webview emits no container for an answer drawn over (quasicoherent 858be2a6). And the store's bar_percent, a fill width computed against the largest project, which is a Meter of the project's share of the window here. 13 new tests, and the four regions the panel's rule reaches: every answer re-declares the call that fetched it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-20 18:55 UTC
Signed with PGP, not checked
Commit: a15c474f9cb634effc20a30b49299fa6cf5fea35
Parent: 3ea4bd5
6 files changed, +798 insertions, -48 deletions
M Cargo.lock +8 -8
@@ -8496,6 +8496,14 @@
8496 8496 "winnow 1.0.4",
8497 8497 ]
8498 8498
8499 + [[patch.unused]]
8500 + name = "ops-status"
8501 + version = "0.1.0"
8502 +
8503 + [[patch.unused]]
8504 + name = "makeover-immediate"
8505 + version = "0.30.0"
8506 +
8499 8507 [[patch.unused]]
8500 8508 name = "quasi-axum"
8501 8509 version = "0.44.0"
@@ -8511,11 +8519,3 @@
8511 8519 [[patch.unused]]
8512 8520 name = "quasi-store"
8513 8521 version = "0.1.0"
8514 -
8515 - [[patch.unused]]
8516 - name = "ops-status"
8517 - version = "0.1.0"
8518 -
8519 - [[patch.unused]]
8520 - name = "makeover-immediate"
8521 - version = "0.30.0"
@@ -131,6 +131,15 @@
131 131 pub async fn get_time_summary_panel(
132 132 state: State<'_, Arc<AppState>>,
133 133 ) -> Result<TimeSummaryPanel, ApiError> {
134 + time_summary_panel(&state)
135 + }
136 +
137 + /// The summary panel, for a caller that is not a command.
138 + ///
139 + /// `crate::quasi::time_tracking` describes the same panel and reads it here
140 + /// rather than repeating the week window, which is the one piece of date math
141 + /// the JS was deliberately relieved of.
142 + pub fn time_summary_panel(state: &AppState) -> Result<TimeSummaryPanel, ApiError> {
134 143 let now = Local::now();
135 144 let days_from_monday = i64::from(now.weekday().num_days_from_monday());
136 145 let week_start = now.date_naive() - Duration::days(days_from_monday);
@@ -157,6 +166,12 @@
157 166 state: State<'_, Arc<AppState>>,
158 167 days: Option<i64>,
159 168 ) -> Result<TimeReport, ApiError> {
169 + time_report(&state, days)
170 + }
171 +
172 + /// The report, for a caller that is not a command. See
173 + /// [`time_summary_panel`] for why both shapes exist.
174 + pub fn time_report(state: &AppState, days: Option<i64>) -> Result<TimeReport, ApiError> {
160 175 // Clamped rather than rejected: the window is a view control, and a silly
161 176 // value should show a silly-but-sane range instead of an error dialog.
162 177 let days = days.unwrap_or(7).clamp(1, 365);
@@ -262,15 +262,6 @@
262 262 });
263 263 }
264 264
265 - if response.time_tracked_today > 0 {
266 - let hours = response.time_tracked_today / 60;
267 - let minutes = response.time_tracked_today % 60;
268 - band = band.with(Node::Text {
269 - text: format!("Tracked today: {hours}h {minutes:02}m"),
270 - tone: Tone::Neutral,
271 - });
272 - }
273 -
274 265 band
275 266 }
276 267
@@ -288,6 +279,14 @@
288 279 Ok(Screen::list_detail("Day", false)
289 280 .with(band(date, &response))
290 281 .with(axis)
282 + // The sidebar's tracked-time panel, which is `super::time_tracking`'s
283 + // region drawn here. It was one line reading "Tracked today: 3h 05m"
284 + // until 2026-08-20, which is a thin version of what
285 + // `time-summary.js` renders into this sidebar: the same total, and
286 + // under it the week broken down by project. Two claims about today's
287 + // total on one screen would be one too many, so the line went when the
288 + // panel arrived.
289 + .with(super::time_tracking::summary(state)?)
291 290 .with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response)))
292 291 .into())
293 292 }
@@ -42,7 +42,7 @@
42 42 //! exist and both are counted. The count starts falling at the flip. Progress is
43 43 //! the first list, not the number.
44 44 //!
45 - //! ## Described, and retires at the flip (30 files, 230 sites)
45 + //! ## Described, and retires at the flip (32 files, 239 sites)
46 46 //!
47 47 //! | Module | JS counterpart | Sites |
48 48 //! |---|---|---|
@@ -58,16 +58,24 @@
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 + //! | [`time_tracking`] | `time-tracking.js` 6, `time-summary.js` 3 | 9 |
62 62 //!
63 63 //! [`projects`] carries its dashboard as a submodule, which is the thirteenth
64 64 //! described screen against twelve modules here.
65 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.
66 + //! [`time_tracking`] is the one row here that is two things: the app's chrome,
67 + //! described because the floating widget belongs to no screen, and the Timer
68 + //! screen behind it. The widget was the row on its own until 2026-08-20 and
69 + //! carried none of the file's `esc()` sites, because it writes through
70 + //! `textContent` and it is the sub-view, the report and the log-time modal that
71 + //! concatenate. Those are described now, `time-summary.js` with them, so both
72 + //! files retire at the flip and the row carries all nine.
73 + //!
74 + //! The row is not whole even so, and the module header says which part: the
75 + //! Focus control and its countdown are left out, waiting on two columns on the
76 + //! session (goingson `44cfcac7`). Its `esc()` sites are inside files that do
77 + //! retire, so the arithmetic here is honest and the screen is not, which is the
78 + //! same shape [`data`] is in.
71 79 //!
72 80 //! `settings.js` went 6 to 11 on 2026-08-19, and gained `notifs.js` beside it
73 81 //! (no sites of its own). Both are the notification kinds reaching the shipped
@@ -86,7 +94,7 @@
86 94 //! These 50 never reach zero by porting. Retiring `escape.js` means giving them
87 95 //! typed escaping some other way, or accepting that four files keep an escaper.
88 96 //!
89 - //! ## Un-ported screens (4 files, 25 sites)
97 + //! ## Un-ported screens (2 files, 16 sites)
90 98 //!
91 99 //! A described counterpart could exist and does not. This is the candidate list,
92 100 //! and it is the only place to look for what is portable next.
@@ -100,16 +108,12 @@
100 108 //! a save destination has no word in the vocabulary and a described write cannot
101 109 //! be offloaded. Their `esc()` sites are inside files that do retire, so the
102 110 //! arithmetic here is honest and the screen is not yet whole. [`data`]'s findings
103 - //! 1 and 2 are what the flip waits on.
111 + //! 1 and 2 are what the flip waits on. `time-tracking.js` and `time-summary.js`
112 + //! left on 2026-08-20 and are [`time_tracking`]'s row above.
104 113 //!
105 114 //! - `events.js` 10. Its grid rendering is bespoke; whether the CRUD/list half
106 115 //! is a real candidate is unchecked, and per this table that is a claim to
107 116 //! measure rather than assert.
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.
113 117 //! - `task-graph.js` 6. Draws the blocking DAG.
114 118 //!
115 119 //! ## Not a screen (10 files, 27 sites)
@@ -1,4 +1,4 @@
1 - //! The running-timer widget, described as chrome rather than as a screen.
1 + //! Time tracking: the running-timer chrome, and the Timer screen behind it.
2 2 //!
3 3 //! <!-- wiki: quasi-overview -->
4 4 //!
@@ -11,15 +11,68 @@
11 11 //! `46fc763c` grew [`Chrome::panel`](quasi_router::Chrome) for it. This module
12 12 //! is the consumer.
13 13 //!
14 + //! The widget is a third of that file. The rest is a screen: the Timer
15 + //! sub-view, its per-project report, the focus split and the retroactive
16 + //! log-time modal, described here as of 2026-08-20 along with
17 + //! `time-summary.js`, the same report's smaller sibling in the day view's
18 + //! sidebar.
19 + //!
14 20 //! # The shape
15 21 //!
22 + //! The chrome:
23 + //!
16 24 //! - [`chrome`] — the panel declaration, held beside the router by
17 25 //! [`super::protocol`].
18 - //! - `GET /timer` — what the panel holds right now.
26 + //! - `GET /timer/panel` — what the panel holds right now.
19 27 //! - `POST /timer/start` — begin timing a task, carrying `task`.
20 28 //! - `POST /timer/stop` — stop the running timer and record the time.
21 29 //! - `POST /timer/discard` — stop it and record nothing.
22 30 //!
31 + //! The screen:
32 + //!
33 + //! - `GET /timer` — the Timer screen, under the split and window its address
34 + //! carries.
35 + //! - `GET /timer/report` — the report alone, which is what changing the window
36 + //! replaces.
37 + //! - `GET /timer/summary` — the day view's tracked-time panel.
38 + //! - `POST /timer/view/track` — start timing a task from the screen.
39 + //! - `POST /timer/view/stop`, `POST /timer/view/discard` — the screen's own
40 + //! copies of the panel's two, answering the screen's regions rather than the
41 + //! panel's.
42 + //! - `POST /timer/view/log` — record time that was never timed.
43 + //!
44 + //! The panel gave up `/timer` to the screen when the screen arrived. Screens
45 + //! own the plain addresses everywhere else in this module tree, and the panel
46 + //! is the smaller thing hanging off one.
47 + //!
48 + //! # The focus split is an address, not a variable
49 + //!
50 + //! `focusWorkMinutes` and `focusBreakMinutes` are module-scope variables in the
51 + //! JS, read by the Focus control and forgotten on reload. Here they are
52 + //! `work` and `break` on the address, per the rule the monthly review's month
53 + //! settled: every control the screen draws carries them, so a reload lands on
54 + //! the same split and a link can name one. Out-of-range values are clamped
55 + //! rather than refused, which is what `updateFocusSplit` does with
56 + //! `Math.max`/`Math.min`, and for the same reason: it is a view control and a
57 + //! silly number should show a sane one.
58 + //!
59 + //! `days`, the report's window, rides the same address for the same reason.
60 + //!
61 + //! # Why the log-time modal is not a modal
62 + //!
63 + //! `openLogTimeModal` builds a form in a dialog: minutes, a date, Cancel and
64 + //! Log Time. Described, it is the row's own Log control asking for those two
65 + //! values before it calls, which is [`Act::asking`]. A
66 + //! [`RegionKind::Modal`](quasi_router::RegionKind::Modal) would need an
67 + //! address per task to open at and a second arrangement for the screen to
68 + //! describe, which is the refusal [`super::task_list`] already recorded for
69 + //! Edit; and a [`Node::Form`] loses the verb, because nothing in it says the
70 + //! two boxes belong to Log rather than to the screen.
71 + //!
72 + //! Where the two boxes are drawn is the renderer's, and a dialog is one of the
73 + //! answers: quasi-webview puts them in a `<details>` under the control, a
74 + //! terminal beside it. Both send the same values.
75 + //!
23 76 //! # Why the readout is an instant and not a number
24 77 //!
25 78 //! `updateElapsed` subtracts `Date.now()` from the start time every second, in
@@ -51,7 +104,7 @@
51 104 //! incidental: the swap replaces the element, so a region answered without them
52 105 //! is a panel that updates once and then never again.
53 106 //!
54 - //! # Two things the port does not carry
107 + //! # What the port does not carry
55 108 //!
56 109 //! **1. The mode label.** The widget says "Tracking" or "Focus session", and
57 110 //! `activeMode` is a variable in the JS process. Both features write the same
@@ -70,6 +123,44 @@
70 123 //! placement question the renderer already answers with CSS). A renderer that
71 124 //! could be handed a fresh chrome per answer would not need that rule, and that
72 125 //! is a quasi finding rather than a goingson one.
126 + //!
127 + //! **3. The Focus control, and the countdown behind it.** The shipped Timer
128 + //! sub-view offers Track and Focus on every row. Only Track is here, and the
129 + //! reason is fact 1 twice over rather than effort.
130 + //!
131 + //! What Focus does that Track does not is draw `focus-timer.js`'s full-screen
132 + //! countdown. Two things stop that being described, and either alone is enough:
133 + //!
134 + //! - **The store cannot rebuild it.** A countdown is [`Node::Until`] holding
135 + //! the instant it ends at, and no column carries one. Computing it from
136 + //! `started_at` plus the split on the address only works while the address
137 + //! says the split the session was started under, and the address says a split
138 + //! whether or not a focus session is running — so every tracked timer would
139 + //! draw a countdown too. That is fact 1: the session does not record which
140 + //! feature started it.
141 + //! - **This app has nowhere to draw it.** An overlay is
142 + //! [`Outcome::Over`](quasi_router::Outcome::Over), and quasi-webview emits the
143 + //! container it retargets at only alongside a
144 + //! [`Chrome`](quasi_router::Chrome) binding (`chrome::chrome_html`). This
145 + //! app's chrome is a panel and no bindings, so an answer drawn over would be
146 + //! retargeted at an element the document does not have. Declaring a binding
147 + //! nothing binds, to get a container, is not an answer.
148 + //!
149 + //! So the split is described and the control that would spend it is not. What
150 + //! the split feeds meanwhile is the Focus card, which says what a focus session
151 + //! would be; a Focus button whose only difference from Track is the sentence in
152 + //! its toast would be worse than its absence. Both blockers are filed rather
153 + //! than worked around: goingson `44cfcac7` for the columns, quasicoherent
154 + //! `858be2a6` for the container.
155 + //!
156 + //! **4. Bar widths.** `TimeReportProject::bar_percent` and its sibling on the
157 + //! summary are a fill width computed against the largest project, which is a
158 + //! drawing decision that reached the store because the JS needed a number for a
159 + //! `style="width:"`. A description says the proportion and lets the renderer
160 + //! draw it, so both here are a [`Meter`] of the project's minutes over the
161 + //! window's total. The number a renderer arrives at differs from the shipped
162 + //! bar, deliberately: a meter's total has to be a set the part is part of, and
163 + //! "of the biggest project" is not one.
73 164
74 165 // Handlers take their request by value because `quasi_router::Handler` is a
75 166 // plain `fn(&S, Request)` pointer, so the signature is the router's.
@@ -77,10 +168,11 @@
77 168
78 169 use std::time::{Duration, SystemTime};
79 170
80 - use goingson_core::{TaskId, TimeSession};
171 + use chrono::TimeZone as _;
172 + use goingson_core::{Task, TaskFilterQuery, TaskId, TaskStatus, TimeSession};
81 173 use makeover_layout::Tone;
82 - use quasi_router::screen::Act;
83 - use quasi_router::{Action, Chrome, Node, RegionKind, Response, RouteError, Router, Slot};
174 + use quasi_router::screen::{Act, Field, Figure, Meter, Row, Tag};
175 + use quasi_router::{Action, Chrome, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
84 176
85 177 use crate::state::{AppState, DESKTOP_USER_ID};
86 178
@@ -114,7 +206,7 @@
114 206 /// The empty band, carrying the address and the call every answer repeats.
115 207 fn band() -> Slot {
116 208 Slot::new(BODY, RegionKind::Band)
117 - .fed_by(Action::get("/timer"))
209 + .fed_by(Action::get("/timer/panel"))
118 210 .live()
119 211 }
120 212
@@ -128,13 +220,18 @@
128 220 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds))
129 221 }
130 222
131 - /// A tracked span in the widget's own words: `1h 5m`, or `5m` under the hour.
132 - fn tracked(minutes: i32) -> String {
223 + /// A span of tracked time in words: `1h 5m`, `3h`, or `5m` under the hour.
224 + ///
225 + /// One spelling for the whole module. The widget and the report each had their
226 + /// own in the JS and they disagreed on the exact hour, where `stopActive` says
227 + /// `3h 0m` and the report's `fmtMinutes` says `3h`. The report's is the one
228 + /// kept: the zero says nothing.
229 + fn spans(minutes: i32) -> String {
133 230 let minutes = minutes.max(0);
134 - if minutes >= 60 {
135 - format!("{}h {}m", minutes / 60, minutes % 60)
136 - } else {
137 - format!("{minutes}m")
231 + match (minutes / 60, minutes % 60) {
232 + (0, rest) => format!("{rest}m"),
233 + (hours, 0) => format!("{hours}h"),
234 + (hours, rest) => format!("{hours}h {rest}m"),
138 235 }
139 236 }
140 237
@@ -240,7 +337,7 @@
240 337 let recorded = stopped
241 338 .and_then(|session| session.duration_minutes)
242 339 .unwrap_or(0);
243 - Ok(panel(state)?.toast(Tone::Success, format!("Tracked {}", tracked(recorded))))
340 + Ok(panel(state)?.toast(Tone::Success, format!("Tracked {}", spans(recorded))))
244 341 }
245 342
246 343 /// Stop the running timer and record nothing.
@@ -262,11 +359,563 @@
262 359 Ok(panel(state)?.toast(Tone::Info, "Timer discarded."))
263 360 }
264 361
265 - /// The panel's routes.
362 + // The Timer screen.
363 +
364 + /// The running session, on the screen rather than in the panel.
365 + const SESSION: &str = "timer-session";
366 +
367 + /// What can be tracked, and the controls that do it.
368 + const CHOICES: &str = "timer-choices";
369 +
370 + /// The per-project report.
371 + const REPORT: &str = "timer-report";
372 +
373 + /// The day view's tracked-time panel.
374 + ///
375 + /// Public because the day view places it: it is this module's region drawn on
376 + /// [`super::day_planning`]'s screen, the way `time-summary.js` renders into the
377 + /// day sidebar's container.
378 + pub const SUMMARY: &str = "time-summary";
379 +
380 + /// The focus split's two halves, and the report's window, when the address says
381 + /// nothing. `focusWorkMinutes`, `focusBreakMinutes` and `reportDays`.
382 + const WORK: i64 = 25;
383 + const BREAK: i64 = 5;
384 + const DAYS: i64 = 7;
385 +
386 + /// The windows the report offers. `ranges` in `loadReport`.
387 + const WINDOWS: [i64; 3] = [7, 30, 90];
388 +
389 + /// How many tasks the screen offers to track. `limit: 200` in `loadTimerView`.
390 + const OFFERED: i64 = 200;
391 +
392 + /// What the Timer screen's address carries.
393 + ///
394 + /// Three numbers that were three module-scope variables in the JS. See the
395 + /// module header: they are the address here, so a reload lands where the user
396 + /// left off and every control the screen draws carries them.
397 + #[derive(Clone, Copy)]
398 + struct View {
399 + /// Minutes of work a focus session would be.
400 + work: i64,
401 + /// Minutes of break after it. `break` is a keyword.
402 + rest: i64,
403 + /// How many days back the report reads.
404 + days: i64,
405 + }
406 +
407 + impl Default for View {
408 + fn default() -> Self {
409 + Self {
410 + work: WORK,
411 + rest: BREAK,
412 + days: DAYS,
413 + }
414 + }
415 + }
416 +
417 + impl View {
418 + /// The view a request was made under.
419 + ///
420 + /// Clamped rather than refused, and read from the payload as well as the
421 + /// address because a field writes its value into the one and its view into
422 + /// the other.
423 + fn of(request: &quasi_router::Request) -> Self {
424 + let read = |name: &str, fallback: i64, low: i64, high: i64| {
425 + request
426 + .payload
427 + .get(name)
428 + .or_else(|| request.carried.get(name))
429 + .and_then(|raw| raw.parse::<i64>().ok())
430 + .unwrap_or(fallback)
431 + .clamp(low, high)
432 + };
433 + Self {
434 + work: read("work", WORK, 1, 240),
435 + rest: read("break", BREAK, 1, 60),
436 + days: read("days", DAYS, 1, 365),
437 + }
438 + }
439 +
440 + /// The same control, still pointed at the view it was offered under.
441 + fn carry(self, action: Action) -> Action {
442 + action
443 + .carrying("work", self.work.to_string())
444 + .carrying("break", self.rest.to_string())
445 + .carrying("days", self.days.to_string())
446 + }
447 +
448 + /// The screen's address as a URL, for the answers that are not a navigation and
449 + /// still move the reader.
450 + ///
451 + /// Written out here because an [`Action`] is not a string until a renderer
452 + /// makes it one, and three integers need no escaping.
453 + fn url(self) -> String {
454 + format!(
455 + "/timer?work={}&break={}&days={}",
456 + self.work, self.rest, self.days
457 + )
458 + }
459 + }
460 +
461 + /// A number inside bounds that are a rule rather than a track.
462 + ///
463 + /// [`Field::range`]'s cousin and deliberately not it: these are typed, and a
464 + /// value outside the bounds is a thing to be told about rather than a place the
465 + /// control cannot reach. `makeover_layout::FieldKind::Range`'s own docs name
466 + /// this exact case.
467 + fn bounded(name: &'static str, label: &str, value: i64, low: i64, high: i64) -> Field {
468 + Field {
469 + min: Some(low.to_string()),
470 + max: Some(high.to_string()),
471 + value: Some(value.to_string()),
472 + ..Field::new(makeover_layout::FieldKind::Number, name, label)
473 + }
474 + }
475 +
476 + /// The two features, named, and the split a focus session would run to.
477 + ///
478 + /// The cards are the JS's own two paragraphs. They are here for the reason it
479 + /// gives: both features write the same session, so without saying so the two
480 + /// controls read as two words for one button.
481 + ///
482 + /// The split's fields carry every part of the view except their own, because a
483 + /// field sends its value under its own name and an address carrying it too
484 + /// would be answering with the value the user just replaced.
485 + fn modes(view: View) -> Slot {
486 + let work = bounded("work", "Minutes of work", view.work, 1, 240).changes(
487 + Action::get("/timer")
488 + .carrying("break", view.rest.to_string())
489 + .carrying("days", view.days.to_string()),
490 + );
491 + let rest = bounded("break", "Minutes of break", view.rest, 1, 60).changes(
492 + Action::get("/timer")
493 + .carrying("work", view.work.to_string())
494 + .carrying("days", view.days.to_string()),
495 + );
496 +
497 + Slot::group("timer-modes")
498 + .with(Node::section("Track"))
499 + .with(Node::text(
500 + "An open-ended stopwatch. Runs until you stop it, and records the time \
501 + against the task.",
502 + ))
503 + .with(Node::section("Focus"))
504 + .with(Node::text(format!(
505 + "A countdown of {} minutes, then a {} minute break. Records the same time.",
506 + view.work, view.rest
507 + )))
508 + .with(Node::field(work))
509 + .with(Node::field(rest))
510 + }
511 +
512 + /// What is running, as the screen's own band.
513 + ///
514 + /// The panel says the same thing at the bottom of every screen, and this is not
515 + /// that region answered twice: the two are separate elements with separate
516 + /// addresses, and this one's Stop answers the screen while the panel's answers
517 + /// the panel. The panel catches up on its own cadence, which is what
518 + /// [`Slot::live`] is for.
519 + fn session(state: &AppState, view: View) -> Result<Slot, RouteError> {
520 + let running = state
521 + .tasks
522 + .get_active_timer(DESKTOP_USER_ID)
523 + .map_err(|error| RouteError::internal(error.to_string()))?;
524 +
525 + let slot = Slot::new(SESSION, RegionKind::Band);
526 + let Some((session, description)) = running else {
527 + return Ok(slot.with(Node::empty("Nothing is being tracked.")));
528 + };
529 +
530 + let mut slot = slot.with(Node::text(description));
531 + if let Some(at) = started(&session) {
532 + slot = slot.with(Node::Since { at });
533 + }
534 +
535 + Ok(slot
536 + .with(Node::Act(
537 + Act::new("Stop", view.carry(Action::post("/timer/view/stop"))).tone(Tone::Success),
538 + ))
539 + .with(Node::Act(
540 + Act::new("Discard", view.carry(Action::post("/timer/view/discard")))
541 + .tone(Tone::Danger)
542 + .confirm("Discard the time this timer has tracked?"),
543 + )))
544 + }
545 +
546 + /// The tasks a timer can be started on, most likely first.
547 + ///
548 + /// Started before Pending, which is `loadTimerView`'s order and its reason: a
549 + /// task already under way is the one being worked on. The running task is left
550 + /// out, because the band above already holds it.
551 + fn offered(state: &AppState, running: Option<TaskId>) -> Result<Vec<Task>, RouteError> {
552 + let mut out = Vec::new();
553 + for status in [TaskStatus::Started, TaskStatus::Pending] {
554 + let (tasks, _) = state
555 + .tasks
556 + .list_filtered(
557 + DESKTOP_USER_ID,
558 + TaskFilterQuery {
559 + status: Some(status),
560 + project_id: None,
561 + milestone_id: None,
562 + priority: None,
563 + show_snoozed: false,
564 + waiting_only: false,
565 + offset: Some(0),
566 + limit: Some(OFFERED),
567 + sort_column: None,
568 + sort_direction: None,
569 + },
570 + )
571 + .map_err(|error| RouteError::internal(error.to_string()))?;
572 + out.extend(tasks.into_iter().filter(|task| Some(task.id) != running));
573 + }
574 + Ok(out)
575 + }
576 +
577 + /// One task, with what can be done to it.
578 + ///
579 + /// # What the row does not offer
580 + ///
581 + /// Focus, which the shipped row has beside Track. Finding 3 in the module
582 + /// header, and it is the session's missing columns rather than anything this
583 + /// row could say.
584 + fn row_for(task: &Task, view: View, busy: bool) -> Row {
585 + let mut row = Row::new(&task.title).meta(task.project_name_or_dash().to_owned());
586 +
587 + if let Some(estimate) = task.estimated_minutes {
588 + row = row.meta(format!("{} est", spans(estimate)));
589 + }
590 + if task.actual_minutes > 0 {
591 + row = row.meta(format!("{} tracked", spans(task.actual_minutes)));
592 + }
593 + if let Some(marker) = super::Availability::of(task).marker() {
594 + row = row.token(marker);
595 + }
596 +
597 + // Disabled rather than absent while something else is running: the store
598 + // allows one timer per user, so the control is real and momentarily
599 + // refused, and a row that lost its buttons would read as a task that cannot
600 + // be tracked at all.
601 + let mut track = Act::new(
602 + "Track",
603 + view.carry(Action::post("/timer/view/track"))
604 + .with("task", task.id.to_string()),
605 + )
606 + .tone(Tone::Success);
607 + if busy {
608 + track = track.disabled();
609 + }
610 +
611 + // The log-time modal, as the control that opens it. See the module header
612 + // for why it is not a modal here.
613 + let log = Act::new(
614 + "Log",
615 + view.carry(Action::post("/timer/view/log"))
616 + .with("task", task.id.to_string()),
617 + )
618 + .asking(bounded("minutes", "Minutes", 30, 1, 1440).required())
619 + .asking(
620 + Field::new(makeover_layout::FieldKind::Date, "date", "Date")
621 + .value(chrono::Local::now().date_naive().to_string()),
622 + );
623 +
624 + row.act(track).act(log)
625 + }
626 +
627 + /// What a timer can be started on, as a region.
628 + fn choices(state: &AppState, view: View) -> Result<Slot, RouteError> {
629 + let running = state
630 + .tasks
631 + .get_active_timer(DESKTOP_USER_ID)
632 + .map_err(|error| RouteError::internal(error.to_string()))?
633 + .map(|(session, _)| session.task_id);
634 +
635 + let tasks = offered(state, running)?;
636 + let slot = Slot::new(CHOICES, RegionKind::Pane);
637 +
638 + if tasks.is_empty() {
639 + return Ok(slot.with(Node::empty("No pending or started tasks to track.")));
640 + }
641 +
642 + Ok(slot.with(Node::list(
643 + tasks
644 + .iter()
645 + .map(|task| row_for(task, view, running.is_some())),
646 + )))
647 + }
648 +
649 + /// Where the time went: tracked per project in the window, beside estimated
650 + /// against actual for the same projects.
651 + ///
652 + /// The bar is a [`Meter`] of the project's minutes over everything tracked in
653 + /// the window. Finding 4 in the module header for why it is not the store's
654 + /// `bar_percent`.
655 + fn report(state: &AppState, view: View) -> Result<Slot, RouteError> {
656 + let report = crate::commands::time_report(state, Some(view.days))
657 + .map_err(|error| RouteError::internal(error.to_string()))?;
658 +
659 + let mut slot = Slot::new(REPORT, RegionKind::Pane)
660 + // Re-declared on every answer, the panel's rule and for the panel's
661 + // reason: the swap replaces the element.
662 + .fed_by(view.carry(Action::get("/timer/report")))
663 + .with(Node::section("Where the time went"));
664 +
665 + for window in WINDOWS {
666 + slot = slot.with(Node::Token(
667 + Tag::chip(
668 + format!("{window}d"),
669 + View {
670 + days: window,
671 + ..view
672 + }
673 + .carry(Action::get("/timer/report")),
674 + )
675 + .latched(window == view.days),
676 + ));
677 + }
Lines truncated
@@ -73,7 +73,7 @@
73 73 }
74 74
75 75 fn panel(state: &AppState) -> String {
76 - html(&get(state, "/timer"))
76 + html(&get(state, "/timer/panel"))
77 77 }
78 78
79 79 /// The words inside the readout, or `None` when the panel is not showing one.
@@ -205,7 +205,7 @@
205 205 assert!(slot.fed_by.is_some());
206 206
207 207 let markup = panel(&state);
208 - assert!(markup.contains("hx-get=\"/timer\""), "{markup}");
208 + assert!(markup.contains("hx-get=\"/timer/panel\""), "{markup}");
209 209 assert!(markup.contains("hx-trigger=\"load, every"), "{markup}");
210 210 }
211 211
@@ -327,3 +327,323 @@
327 327 let during = html(&get(&state, &format!("/tasks/{id}")));
328 328 assert!(!during.contains("Track time"), "{during}");
329 329 }
330 +
331 + // The Timer screen.
332 +
333 + /// The screen, under the default view.
334 + fn screen(state: &AppState) -> String {
335 + html(&get(state, "/timer"))
336 + }
337 +
338 + /// The screen, under an address that says something.
339 + fn screen_under(state: &AppState, carried: Params) -> String {
340 + html(
341 + &router()
342 + .handle(state, Request::get("/timer").carrying(carried))
343 + .expect("the route answers"),
344 + )
345 + }
346 +
347 + /// A task carrying an estimate, for the report's other half.
348 + fn estimated(state: &AppState, title: &str, minutes: i32) -> TaskId {
349 + state
350 + .tasks
351 + .create(
352 + DESKTOP_USER_ID,
353 + NewTask::builder(title)
354 + .priority(Priority::Medium)
355 + .estimated_minutes(minutes)
356 + .build(),
357 + )
358 + .unwrap()
359 + .id
360 + }
361 +
362 + /// Time recorded against a task without a timer having run.
363 + fn logged(state: &AppState, id: TaskId, minutes: i32) {
364 + state
365 + .tasks
366 + .log_manual_time(
367 + id,
368 + DESKTOP_USER_ID,
369 + goingson_core::PositiveMinutes::try_new(minutes).unwrap(),
370 + chrono::Utc::now(),
371 + )
372 + .unwrap();
373 + }
374 +
375 + #[tokio::test]
376 + async fn the_screen_offers_a_timer_on_every_task_that_could_take_one() {
377 + let state = state().await;
378 + task(&state, "Write the thing");
379 +
380 + let markup = screen(&state);
381 + assert!(markup.contains("Write the thing"), "{markup}");
382 + assert!(markup.contains("/timer/view/track"), "{markup}");
383 + assert!(markup.contains("/timer/view/log"), "{markup}");
384 + }
385 +
386 + #[tokio::test]
387 + async fn the_task_being_timed_is_the_band_rather_than_a_row_offering_to_start_it() {
388 + let state = state().await;
389 + let id = task(&state, "Write the thing");
390 + let other = task(&state, "Write the other thing");
391 + state
392 + .tasks
393 + .start_timer(id, DESKTOP_USER_ID)
394 + .expect("the timer starts");
395 +
396 + let markup = screen(&state);
397 + assert!(markup.contains("data-clock=\"since\""), "{markup}");
398 + assert!(markup.contains("/timer/view/stop"), "{markup}");
399 +
400 + // Once, in the band. `loadTimerView` drops it from the list for the same
401 + // reason: the band above is already offering to stop it.
402 + assert_eq!(markup.matches("Write the thing").count(), 1, "{markup}");
403 + assert!(markup.contains("Write the other thing"), "{markup}");
404 + assert!(markup.contains("disabled"), "{markup}");
405 + let _ = other;
406 + }
407 +
408 + #[tokio::test]
409 + async fn the_screens_stop_answers_the_screen_and_leaves_the_panel_to_catch_up() {
410 + // Two elements with two addresses. The panel is `live`, so it re-reads on
411 + // its own cadence; answering it from here would swap the wrong region.
412 + let state = state().await;
413 + let id = task(&state, "Write the thing");
414 + state
415 + .tasks
416 + .start_timer(id, DESKTOP_USER_ID)
417 + .expect("the timer starts");
418 +
419 + let stopped = post(&state, "/timer/view/stop", Params::new());
420 + let Outcome::Fragment { region, .. } = &stopped.outcome else {
421 + panic!("the screen's stop answers a region");
422 + };
423 + assert_eq!(region, super::SESSION);
424 + let notice = stopped.notice.as_ref().expect("it says what it recorded");
425 + assert!(notice.text.starts_with("Tracked "), "{}", notice.text);
426 +
427 + // The rows and the report move with it: every Track control is live again
428 + // and the tracked total has changed.
429 + let also: Vec<&str> = stopped
430 + .invalidates
431 + .iter()
432 + .map(|stale| stale.region.as_str())
433 + .collect();
434 + assert_eq!(also, vec![super::CHOICES, super::REPORT]);
435 + }
436 +
437 + #[tokio::test]
438 + async fn the_split_is_the_address_and_the_focus_card_reads_it() {
439 + let state = state().await;
440 + let markup = screen_under(&state, Params::new().with("work", "50").with("break", "10"));
441 + assert!(
442 + markup.contains("A countdown of 50 minutes, then a 10 minute break."),
443 + "{markup}"
444 + );
445 + assert!(markup.contains("value=\"50\""), "{markup}");
446 + assert!(markup.contains("value=\"10\""), "{markup}");
447 +
448 + // Each half carries the other and not itself, or the address would answer
449 + // with the value the reader has just replaced.
450 + assert!(
451 + markup.contains("hx-get=\"/timer?break=10&amp;days=7\""),
452 + "{markup}"
453 + );
454 + assert!(
455 + markup.contains("hx-get=\"/timer?work=50&amp;days=7\""),
456 + "{markup}"
457 + );
458 + }
459 +
460 + #[tokio::test]
461 + async fn a_silly_split_is_clamped_rather_than_refused() {
462 + // `updateFocusSplit`'s `Math.max`/`Math.min`, and a view control's rule:
463 + // an unusable number should show a usable one, not an error page.
464 + let state = state().await;
465 + let markup = screen_under(
466 + &state,
467 + Params::new()
468 + .with("work", "9000")
469 + .with("break", "0")
470 + .with("days", "100000"),
471 + );
472 + assert!(
473 + markup.contains("A countdown of 240 minutes, then a 1 minute break."),
474 + "{markup}"
475 + );
476 + // The window is clamped on the same read, and the report's own call is
477 + // where the clamped value shows.
478 + assert!(markup.contains("days=365"), "{markup}");
479 + }
480 +
481 + #[tokio::test]
482 + async fn no_focus_control_is_offered() {
483 + // Finding 3 in the module header, pinned so it is a decision rather than an
484 + // omission somebody quietly closes: a Focus button whose only difference
485 + // from Track is the sentence in its toast would be worse than its absence.
486 + let state = state().await;
487 + task(&state, "Write the thing");
488 + let markup = screen(&state);
489 + assert!(!markup.contains("/timer/view/focus"), "{markup}");
490 + // The Focus card is still there and says what a focus session would be.
491 + // What is not there is a control that would start one.
492 + assert!(markup.contains("Focus"), "{markup}");
493 + assert!(!markup.contains("Focus</button>"), "{markup}");
494 + }
495 +
496 + #[tokio::test]
497 + async fn the_log_control_asks_for_a_duration_and_a_day_before_it_calls() {
498 + let state = state().await;
499 + let id = task(&state, "Write the thing");
500 +
501 + let markup = screen(&state);
502 + assert!(markup.contains("name=\"minutes\""), "{markup}");
503 + assert!(markup.contains("name=\"date\""), "{markup}");
504 + assert!(markup.contains("type=\"date\""), "{markup}");
505 +
506 + let logged = post(
507 + &state,
508 + "/timer/view/log",
509 + Params::new()
510 + .with("task", id.to_string())
511 + .with("minutes", "90")
512 + .with("date", "2026-08-18"),
513 + );
514 + assert_eq!(
515 + logged.notice.as_ref().expect("it says so").text,
516 + "Logged 1h 30m"
517 + );
518 +
519 + let sessions = state
520 + .tasks
521 + .list_time_sessions(id, DESKTOP_USER_ID)
522 + .expect("read");
523 + assert_eq!(sessions.len(), 1);
524 + assert_eq!(sessions[0].duration_minutes, Some(90));
525 + // Noon on the day asked for, which is `submitLogTime`'s own conversion.
526 + assert_eq!(
527 + sessions[0].started_at.format("%Y-%m-%d").to_string(),
528 + "2026-08-18"
529 + );
530 + }
531 +
532 + #[tokio::test]
533 + async fn logging_nothing_is_refused_rather_than_recorded_as_an_empty_session() {
534 + let state = state().await;
535 + let id = task(&state, "Write the thing");
536 + assert!(
537 + router()
538 + .handle(
539 + &state,
540 + Request::post("/timer/view/log").sending(
541 + Params::new()
542 + .with("task", id.to_string())
543 + .with("minutes", "0")
544 + ),
545 + )
546 + .is_err()
547 + );
548 + assert!(
549 + router()
550 + .handle(
551 + &state,
552 + Request::post("/timer/view/log")
553 + .sending(Params::new().with("task", id.to_string())),
554 + )
555 + .is_err()
556 + );
557 + }
558 +
559 + #[tokio::test]
560 + async fn the_report_says_where_the_time_went_and_offers_three_windows() {
561 + let state = state().await;
562 + let id = estimated(&state, "Write the thing", 60);
563 + logged(&state, id, 90);
564 +
565 + let markup = screen(&state);
566 + assert!(markup.contains("Where the time went"), "{markup}");
567 + assert!(
568 + markup.contains("1h 30m tracked in the last 7 days"),
569 + "{markup}"
570 + );
571 + for window in ["7d", "30d", "90d"] {
572 + assert!(markup.contains(&format!(">{window}<")), "{markup}");
573 + }
574 + // The estimate half: 90 actual against 60 estimated is an overrun, and the
575 + // percentage is the fact the shipped row tones.
576 + assert!(markup.contains("1h est / 1h 30m actual"), "{markup}");
577 + assert!(markup.contains("150%"), "{markup}");
578 + }
579 +
580 + #[tokio::test]
581 + async fn a_project_with_no_estimates_says_so_rather_than_showing_a_percentage_of_nothing() {
582 + let state = state().await;
583 + let id = task(&state, "Write the thing");
584 + logged(&state, id, 30);
585 +
586 + let markup = screen(&state);
587 + assert!(markup.contains("no estimates"), "{markup}");
588 + assert!(!markup.contains(" est / "), "{markup}");
589 + }
590 +
591 + #[tokio::test]
592 + async fn changing_the_window_re_reads_the_report_and_moves_the_address() {
593 + let state = state().await;
594 + let id = task(&state, "Write the thing");
595 + logged(&state, id, 30);
596 +
597 + let answered = router()
598 + .handle(
599 + &state,
600 + Request::get("/timer/report").carrying(Params::new().with("days", "30")),
601 + )
602 + .expect("the route answers");
603 + let Outcome::Fragment { region, .. } = &answered.outcome else {
604 + panic!("the window answers the report alone");
605 + };
606 + assert_eq!(region, super::REPORT);
607 +
608 + // The window is a fact about the view rather than about the region it is
609 + // drawn in, so a reload lands on the same one.
610 + assert_eq!(
611 + answered.address,
612 + Some(quasi_router::Address::Enters(
613 + "/timer?work=25&break=5&days=30".to_owned()
614 + ))
615 + );
616 +
617 + let markup = html(&answered);
618 + assert!(markup.contains("last 30 days"), "{markup}");
619 + // Re-declared on the answer, the panel's rule: the swap replaces the
620 + // element, so an answer that dropped the call is a region that stops asking.
621 + assert!(markup.contains("hx-get=\"/timer/report"), "{markup}");
622 + }
623 +
624 + #[tokio::test]
625 + async fn the_day_view_carries_the_tracked_time_panel() {
626 + // `time-summary.js` renders into the day sidebar, and the described day
627 + // view said one line about today until this arrived.
628 + let state = state().await;
629 + let id = task(&state, "Write the thing");
630 + logged(&state, id, 45);
631 +
632 + let day = html(&get(&state, "/day"));
633 + assert!(day.contains(&format!("id=\"{}\"", super::SUMMARY)), "{day}");
634 + assert!(day.contains("hx-get=\"/timer/summary\""), "{day}");
635 + assert!(day.contains("Time tracked"), "{day}");
636 + assert!(day.contains("45m"), "{day}");
637 + assert!(!day.contains("Tracked today:"), "{day}");
638 + }
639 +
640 + #[tokio::test]
641 + async fn the_summary_answers_at_its_own_address_too() {
642 + let state = state().await;
643 + let answered = get(&state, "/timer/summary");
644 + let Outcome::Fragment { region, .. } = &answered.outcome else {
645 + panic!("the summary answers a region");
646 + };
647 + assert_eq!(region, super::SUMMARY);
648 + assert!(html(&answered).contains("hx-get=\"/timer/summary\""));
649 + }