Skip to main content

max / goingson

43.0 KB · 1179 lines History Blame Raw
1 //! Time tracking: the running-timer chrome, and the Timer screen behind it.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The running-timer band is not part of any screen: it is what the app shows
6 //! while a timer runs, and every screen would otherwise have to repeat it. That
7 //! makes it chrome, and [`Chrome::panel`](quasi_router::Chrome) is what carries
8 //! it. The rest of this module is a screen: the Timer view, its per-project
9 //! report, the focus split, the retroactive log-time control, and the day
10 //! view's tracked-time panel.
11 //!
12 //! # The shape
13 //!
14 //! The chrome:
15 //!
16 //! - [`chrome`] — the panel declaration, held beside the router by
17 //! [`super::protocol`].
18 //! - `GET /timer/panel` — 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 //! The screen:
24 //!
25 //! - `GET /timer` — the Timer screen, under the split and window its address
26 //! carries.
27 //! - `GET /timer/report` — the report alone, which is what changing the window
28 //! replaces.
29 //! - `GET /timer/summary` — the day view's tracked-time panel.
30 //! - `POST /timer/view/track` — start timing a task from the screen.
31 //! - `POST /timer/view/focus` — start a countdown on one, for the split the
32 //! address carries.
33 //! - `POST /timer/view/stop`, `POST /timer/view/discard` — the screen's own
34 //! copies of the panel's two, answering the screen's regions rather than the
35 //! panel's.
36 //! - `POST /timer/view/log` — record time that was never timed.
37 //!
38 //! The screen owns `/timer`; the panel is the smaller thing hanging off it.
39 //!
40 //! # The focus split is an address, not a variable
41 //!
42 //! `work` and `break` ride the address, so a reload lands on the same split and
43 //! a link can name one. Every control the screen draws carries them.
44 //! Out-of-range values are clamped rather than refused: it is a view control,
45 //! and a silly number should show a sane one. `days`, the report's window,
46 //! rides the same address for the same reason.
47 //!
48 //! # Why the log-time modal is not a modal
49 //!
50 //! Minutes and a date, asked for by the row's own Log control before it calls,
51 //! which is [`Act::asking`]. A
52 //! [`RegionKind::Modal`](quasi_router::RegionKind::Modal) would need an address
53 //! per task to open at and a second arrangement for the screen to describe, and
54 //! a [`Node::Form`] loses the verb, because nothing in it says the two boxes
55 //! belong to Log rather than to the screen.
56 //!
57 //! Where the two boxes are drawn is the renderer's: quasi-webview puts them in
58 //! a `<details>` under the control, a terminal beside it. Both send the same
59 //! values.
60 //!
61 //! # Why the readout is an instant and not a number
62 //!
63 //! The description carries the instant and the renderer carries the words and
64 //! the cadence, so the panel says [`Node::Since`] holding the session's
65 //! `started_at` and stops there. The webview emits `data-clock="since"` and its
66 //! own `clock.js` ticks it; a terminal ticks it on its own clock.
67 //!
68 //! A route that answered a formatted elapsed string would be describing the
69 //! moment it ran, and the readout would freeze at whatever the last request
70 //! made it. [`tests::the_readout_is_an_instant_the_renderer_ticks`] is the
71 //! assertion against it.
72 //!
73 //! # Why the panel asks for itself
74 //!
75 //! A timer starts and stops from places the panel knows nothing about: a task
76 //! row, the drawer, the focus countdown. The panel's region is
77 //! [`Slot::fed_by`] its own route and [`Slot::live`], so it asks what is
78 //! running on the renderer's cadence and nothing else has to remember to tell
79 //! it. The three controls that do know answer with the region re-read, so
80 //! pressing Stop does not wait out a cadence.
81 //!
82 //! The answer re-declares `fed_by` and `live`, and that is load-bearing: the
83 //! swap replaces the element, so a region answered without them is a panel that
84 //! updates once and then never again.
85 //!
86 //! # What this does not carry
87 //!
88 //! **1. A focus session started before `mode` and `ends_at` existed.**
89 //! `ends_at` is `None` there and there is no honest value to invent, so the
90 //! band shows the elapsed time and no countdown. Otherwise the band names the
91 //! mode off the session, and a focus session's readout is [`Node::Until`]
92 //! holding the instant it was started for where a tracked one is
93 //! [`Node::Since`] holding its start.
94 //!
95 //! **2. Withdrawing the panel.** Presence is the app's, and the answer here is
96 //! an empty panel: a mount builds its renderer once and holds it, so the
97 //! declaration cannot come and go per request the way the contents can. The
98 //! panel is declared always and holds nothing while nothing is running, and the
99 //! stylesheet is what keeps an empty band from drawing a bar
100 //! (`.chrome-panel:not(:has([data-clock]))` in `styles.css`).
101 //!
102 //! **3. The full-screen focus overlay.** The row offers Focus, spending the
103 //! split the address carries at the moment it is pressed, and draws no
104 //! full-screen countdown. An overlay is
105 //! [`Outcome::Over`](quasi_router::Outcome::Over), and quasi-webview emits the
106 //! container it retargets at only alongside a [`Chrome`](quasi_router::Chrome)
107 //! binding (`chrome::chrome_html`). This app's chrome is a panel and no
108 //! bindings, so an answer drawn over would be retargeted at an element the
109 //! document does not have. Declaring a binding nothing binds, to get a
110 //! container, is not an answer. Filed as quasicoherent `858be2a6`.
111 //!
112 //! **4. Bar widths.** A description says the proportion and lets the renderer
113 //! draw it, so both bars here are a [`Meter`] of the project's minutes over the
114 //! window's total. A meter's total has to be a set the part is part of, and "of
115 //! the biggest project" is not one.
116
117 // Handlers take their request by value because `quasi_router::Handler` is a
118 // plain `fn(&S, Request)` pointer, so the signature is the router's.
119 #![allow(clippy::needless_pass_by_value)]
120
121 use std::time::{Duration, SystemTime};
122
123 use chrono::TimeZone as _;
124 use goingson_core::{
125 Task, TaskFilterQuery, TaskId, TaskStatus, TimeReport, TimeReportProject, TimeSession,
126 TimeSessionMode, TimeSummaryPanel,
127 };
128 use makeover_layout::Tone;
129 use quasi_declare::declare;
130 use quasi_router::screen::{Consult, Figure, Meter, Tag};
131 use quasi_router::{Action, Chrome, Node, Response, Role, RouteError, Router};
132
133 use crate::state::{AppState, DESKTOP_USER_ID};
134
135 #[cfg(test)]
136 mod tests;
137
138 /// The address the chrome panel itself carries.
139 ///
140 /// What a renderer places. Nothing aims an answer here: the contents are the
141 /// region inside it, which is [`BODY`], and the panel keeps the class the
142 /// stylesheet places it by across every swap because the swap never reaches it.
143 pub const PANEL: &str = "timer-panel";
144
145 /// The address the panel's contents answer at.
146 ///
147 /// Separate from [`PANEL`] because they are two elements: the band that is
148 /// always there, and what it is holding at the moment.
149 pub const BODY: &str = "timer";
150
151 /// The panel, empty, asking for its own contents.
152 ///
153 /// Built once and held by the mount, so the body here is what the document
154 /// carries before the first answer arrives: nothing. The region asks
155 /// immediately (`load`, then the renderer's cadence), so "nothing" lasts one
156 /// round trip.
157 ///
158 /// Takes the chrome and gives it back, the way [`routes`] takes the router.
159 /// [`super::shell`] is what assembles the app's furniture now that the band is
160 /// not the whole of it, and this stays the one answer to what the band says.
161 ///
162 /// `Role::Activity`: the band is about a thing in progress, present while a
163 /// timer runs, rather than about the app's condition. goingson's `Status` one
164 /// is the sync indicator, which is not describable; see [`super::shell`].
165 #[must_use]
166 pub fn chrome(chrome: Chrome) -> Chrome {
167 chrome.presenting(PANEL, Role::Activity, Node::Region(band()))
168 }
169
170 declare! {
171 /// The empty band, carrying the address and the call every answer repeats.
172 shape band() -> Slot;
173
174 region BODY as Band {
175 fed_by Action::get("/timer/panel");
176 live;
177 }
178 }
179
180 /// When a session started, as the instant a readout counts from.
181 ///
182 /// `task_list::running_for` does the same conversion for the row readout. Two
183 /// call sites and four lines, so it is spelled twice rather than reached for
184 /// through a module that has nothing else to do with this one.
185 fn started(session: &TimeSession) -> Option<SystemTime> {
186 instant(session.started_at)
187 }
188
189 /// The instant a countdown runs to, if this session is one.
190 fn ending(session: &TimeSession) -> Option<SystemTime> {
191 instant(session.ends_at?)
192 }
193
194 /// A stamp as the clock a renderer ticks against.
195 ///
196 /// `None` only for an instant before the epoch, which nothing in this app can
197 /// write.
198 fn instant(at: chrono::DateTime<chrono::Utc>) -> Option<SystemTime> {
199 let seconds = u64::try_from(at.timestamp()).ok()?;
200 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds))
201 }
202
203 /// A span of tracked time in words: `1h 5m`, `3h`, or `5m` under the hour.
204 ///
205 /// One spelling for the whole module. The widget and the report each had their
206 /// own in the JS and they disagreed on the exact hour, where `stopActive` says
207 /// `3h 0m` and the report's `fmtMinutes` says `3h`. The report's is the one
208 /// kept: the zero says nothing.
209 fn spans(minutes: i32) -> String {
210 let minutes = minutes.max(0);
211 match (minutes / 60, minutes % 60) {
212 (0, rest) => format!("{rest}m"),
213 (hours, 0) => format!("{hours}h"),
214 (hours, rest) => format!("{hours}h {rest}m"),
215 }
216 }
217
218 /// What is being timed, if anything.
219 struct Running {
220 session: TimeSession,
221 /// The task the session is against, as the store reads it out.
222 description: String,
223 }
224
225 /// Read what is running.
226 fn running(state: &AppState) -> Result<Option<Running>, RouteError> {
227 Ok(state
228 .tasks
229 .get_active_timer(DESKTOP_USER_ID)
230 .map_err(|error| RouteError::internal(error.to_string()))?
231 .map(|(session, description)| Running {
232 session,
233 description,
234 }))
235 }
236
237 /// Whether a timer is running at all.
238 fn is_running(running: Option<&Running>) -> bool {
239 running.is_some()
240 }
241
242 /// What the band calls the mode, off the session rather than out of a variable
243 /// in the renderer's process.
244 ///
245 /// Migration 068 is what makes this sayable: before it, a focus session found
246 /// running after a reload read as Tracking because nothing in the store told
247 /// the two apart.
248 fn mode_label(running: Option<&Running>) -> &'static str {
249 running.map_or("", |running| running.session.mode.label())
250 }
251
252 /// The task being timed.
253 fn description(running: Option<&Running>) -> &str {
254 running.map_or("", |running| running.description.as_str())
255 }
256
257 /// The instant a countdown runs to, when the band has one to count to.
258 ///
259 /// `None` for a Track session, which has no end, and for a Focus session
260 /// written before migration 068 gave the column somewhere to live. Both answer
261 /// the same way, because a countdown with no end is a countdown a renderer
262 /// cannot draw and the band says the elapsed time instead.
263 fn counting_down(running: Option<&Running>) -> Option<SystemTime> {
264 let running = running?;
265 match running.session.mode {
266 TimeSessionMode::Focus => ending(&running.session),
267 TimeSessionMode::Track => None,
268 }
269 }
270
271 /// When the running session started, as the instant a readout counts from.
272 fn started_at(running: Option<&Running>) -> Option<SystemTime> {
273 started(&running?.session)
274 }
275
276 /// The instant the band counts up from, when it is not counting down.
277 ///
278 /// The whole of the app's half of the readout: a focus session counts down to
279 /// the instant it was started for and a tracked one counts up from its start,
280 /// which is the one difference between the two bands. Both are an instant the
281 /// renderer ticks; neither is a number a route computed. See the module header.
282 ///
283 /// Both are absent for a stamp before the epoch, and the countdown also for a
284 /// focus session predating the column. The band then says the task and no time
285 /// rather than a zero that would read as a timer that has just started.
286 fn counting_up(running: Option<&Running>) -> Option<SystemTime> {
287 counting_down(running)
288 .is_none()
289 .then(|| started_at(running))?
290 }
291
292 declare! {
293 /// What the panel holds: nothing, or the running timer.
294 ///
295 /// The `fed_by` and `live` of [`band`] are repeated here on purpose. See
296 /// the module header: the answer replaces the element, so an answer that
297 /// dropped them would be a panel that stopped asking.
298 ///
299 /// Discarding throws away time that has already been spent, and unlike the
300 /// widget's ghost button there is no undo behind it, so it confirms. The
301 /// same trade the task list's Delete makes.
302 shape contents(running: Option<&Running>) -> Slot;
303
304 region BODY as Band {
305 fed_by Action::get("/timer/panel");
306 live;
307
308 text mode_label(running) when is_running(running);
309 text description(running) when is_running(running);
310
311 for at in counting_down(running).into_iter() {
312 until at;
313 }
314 for at in counting_up(running).into_iter() {
315 since at;
316 }
317
318 act "Stop" to post "/timer/stop" when is_running(running) {
319 tone Success;
320 }
321
322 act "Discard" to post "/timer/discard" when is_running(running) {
323 tone Danger;
324 confirm "Discard the time this timer has tracked?";
325 }
326 }
327 }
328
329 /// The panel's contents, as an answer.
330 fn panel(state: &AppState) -> Result<Response, RouteError> {
331 Ok(Response::fragment(
332 BODY,
333 Node::Region(contents(running(state)?.as_ref())),
334 ))
335 }
336
337 /// What is running, if anything.
338 fn showing(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
339 panel(state)
340 }
341
342 /// The task a start was asked for.
343 fn asked_for(request: &quasi_router::Request) -> Result<TaskId, RouteError> {
344 let raw = request
345 .payload
346 .get("task")
347 .or_else(|| request.carried.get("task"))
348 .ok_or_else(|| RouteError::not_found("no task id"))?;
349 Ok(TaskId::from(
350 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
351 ))
352 }
353
354 /// Begin timing a task.
355 ///
356 /// The store allows one running timer per user, so starting a second is an
357 /// error rather than a switch. Answered as a complaint on the panel rather than
358 /// as a 500: the user pressed Track on a second task, which is a thing to be
359 /// told about and not a fault.
360 fn start(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
361 let id = asked_for(&request)?;
362 match state.tasks.start_timer(id, DESKTOP_USER_ID) {
363 Ok(_) => Ok(panel(state)?.toast(Tone::Success, "Timer started.")),
364 Err(error) => Ok(panel(state)?.toast(Tone::Warning, error.to_string())),
365 }
366 }
367
368 /// Stop the running timer and record what it tracked.
369 ///
370 /// Reads what is running rather than being told, which is what `stopActive`
371 /// does through `getActive`: the panel's own control cannot name a task the
372 /// panel is not showing, and being told would let a stale one stop a timer
373 /// started since.
374 fn stop(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
375 let running = state
376 .tasks
377 .get_active_timer(DESKTOP_USER_ID)
378 .map_err(|error| RouteError::internal(error.to_string()))?;
379
380 // Nothing running. The panel is re-read rather than erroring, for the same
381 // reason `stopActive` returns early: the timer stopped somewhere else and
382 // the panel is the thing that is out of date.
383 let Some((session, _)) = running else {
384 return panel(state);
385 };
386
387 let stopped = state
388 .tasks
389 .stop_timer(session.task_id, DESKTOP_USER_ID)
390 .map_err(|error| RouteError::internal(error.to_string()))?;
391
392 let recorded = stopped
393 .and_then(|session| session.duration_minutes)
394 .unwrap_or(0);
395 Ok(panel(state)?.toast(Tone::Success, format!("Tracked {}", spans(recorded))))
396 }
397
398 /// Stop the running timer and record nothing.
399 fn discard(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
400 let running = state
401 .tasks
402 .get_active_timer(DESKTOP_USER_ID)
403 .map_err(|error| RouteError::internal(error.to_string()))?;
404
405 let Some((session, _)) = running else {
406 return panel(state);
407 };
408
409 state
410 .tasks
411 .discard_timer(session.task_id, DESKTOP_USER_ID)
412 .map_err(|error| RouteError::internal(error.to_string()))?;
413
414 Ok(panel(state)?.toast(Tone::Info, "Timer discarded."))
415 }
416
417 // The Timer screen.
418
419 /// The running session, on the screen rather than in the panel.
420 const SESSION: &str = "timer-session";
421
422 /// What can be tracked, and the controls that do it.
423 const CHOICES: &str = "timer-choices";
424
425 /// The per-project report.
426 const REPORT: &str = "timer-report";
427
428 /// The day view's tracked-time panel.
429 ///
430 /// Public because the day view places it: it is this module's region drawn on
431 /// [`super::day_planning`]'s screen, the way `time-summary.js` renders into the
432 /// day sidebar's container.
433 pub const SUMMARY: &str = "time-summary";
434
435 /// The focus split's two halves, and the report's window, when the address says
436 /// nothing. `focusWorkMinutes`, `focusBreakMinutes` and `reportDays`.
437 const WORK: i64 = 25;
438 const BREAK: i64 = 5;
439 const DAYS: i64 = 7;
440
441 /// The windows the report offers. `ranges` in `loadReport`.
442 const WINDOWS: [i64; 3] = [7, 30, 90];
443
444 /// How many tasks the screen offers to track. `limit: 200` in `loadTimerView`.
445 const OFFERED: i64 = 200;
446
447 /// What the Timer screen's address carries.
448 ///
449 /// Three numbers that were three module-scope variables in the JS. See the
450 /// module header: they are the address here, so a reload lands where the user
451 /// left off and every control the screen draws carries them.
452 #[derive(Clone, Copy)]
453 struct View {
454 /// Minutes of work a focus session would be.
455 work: i64,
456 /// Minutes of break after it. `break` is a keyword.
457 rest: i64,
458 /// How many days back the report reads.
459 days: i64,
460 }
461
462 impl Default for View {
463 fn default() -> Self {
464 Self {
465 work: WORK,
466 rest: BREAK,
467 days: DAYS,
468 }
469 }
470 }
471
472 impl View {
473 /// The view a request was made under.
474 ///
475 /// Clamped rather than refused, and read from the payload as well as the
476 /// address because a field writes its value into the one and its view into
477 /// the other.
478 fn of(request: &quasi_router::Request) -> Self {
479 let read = |name: &str, fallback: i64, low: i64, high: i64| {
480 request
481 .payload
482 .get(name)
483 .or_else(|| request.carried.get(name))
484 .and_then(|raw| raw.parse::<i64>().ok())
485 .unwrap_or(fallback)
486 .clamp(low, high)
487 };
488 Self {
489 work: read("work", WORK, 1, 240),
490 rest: read("break", BREAK, 1, 60),
491 days: read("days", DAYS, 1, 365),
492 }
493 }
494
495 /// The same control, still pointed at the view it was offered under.
496 fn carry(self, action: Action) -> Action {
497 action
498 .carrying("work", self.work.to_string())
499 .carrying("break", self.rest.to_string())
500 .carrying("days", self.days.to_string())
501 }
502
503 /// The screen's address as a URL, for the answers that are not a navigation and
504 /// still move the reader.
505 ///
506 /// Written out here because an [`Action`] is not a string until a renderer
507 /// makes it one, and three integers need no escaping.
508 fn url(self) -> String {
509 format!(
510 "/timer?work={}&break={}&days={}",
511 self.work, self.rest, self.days
512 )
513 }
514 }
515
516 declare! {
517 /// The two features, named, and the split a focus session would run to.
518 ///
519 /// The cards are the JS's own two paragraphs. They are here for the reason
520 /// it gives: both features write the same session, so without saying so the
521 /// two controls read as two words for one button.
522 ///
523 /// The two numbers are bounds that are a rule rather than a track, which is
524 /// why they are typed rather than [`Field::range`]: a value outside them is
525 /// a thing to be told about rather than a place the control cannot reach.
526 /// `makeover_layout::FieldKind::Range`'s own docs name this exact case.
527 ///
528 /// Each consults the screen carrying every part of the view except its own,
529 /// because a field sends its value under its own name and an address
530 /// carrying it too would answer with the value the user just replaced.
531 shape modes(view: View) -> Slot;
532
533 region "timer-modes" as Group {
534 section "Track";
535 text "An open-ended stopwatch. Runs until you stop it, and records the time \
536 against the task.";
537
538 section "Focus";
539 text "A countdown of {view.work} minutes, then a {view.rest} minute break. \
540 Records the same time.";
541
542 field Number "work" "Minutes of work" {
543 within "1" "240";
544 value view.work.to_string();
545 consulting Consult::new(
546 Action::get("/timer")
547 .carrying("break", view.rest.to_string())
548 .carrying("days", view.days.to_string())
549 );
550 }
551
552 field Number "break" "Minutes of break" {
553 within "1" "60";
554 value view.rest.to_string();
555 consulting Consult::new(
556 Action::get("/timer")
557 .carrying("work", view.work.to_string())
558 .carrying("days", view.days.to_string())
559 );
560 }
561 }
562 }
563
564 declare! {
565 /// What is running, as the screen's own band.
566 ///
567 /// The panel says the same thing at the bottom of every screen, and this is
568 /// not that region answered twice: the two are separate elements with
569 /// separate addresses, and this one's Stop answers the screen while the
570 /// panel's answers the panel. The panel catches up on its own cadence,
571 /// which is what [`Slot::live`] is for.
572 shape session(running: Option<&Running>, view: View) -> Slot;
573
574 region SESSION as Band {
575 empty "Nothing is being tracked." unless is_running(running);
576
577 text description(running) when is_running(running);
578
579 for at in started_at(running).into_iter() {
580 since at;
581 }
582
583 act "Stop" to doing view.carry(Action::post("/timer/view/stop"))
584 when is_running(running) {
585 tone Success;
586 }
587
588 act "Discard" to doing view.carry(Action::post("/timer/view/discard"))
589 when is_running(running) {
590 tone Danger;
591 confirm "Discard the time this timer has tracked?";
592 }
593 }
594 }
595
596 /// The tasks a timer can be started on, most likely first.
597 ///
598 /// Started before Pending, which is `loadTimerView`'s order and its reason: a
599 /// task already under way is the one being worked on. The running task is left
600 /// out, because the band above already holds it.
601 fn offered(state: &AppState, running: Option<TaskId>) -> Result<Vec<Task>, RouteError> {
602 let mut out = Vec::new();
603 for status in [TaskStatus::Started, TaskStatus::Pending] {
604 let (tasks, _) = state
605 .tasks
606 .list_filtered(
607 DESKTOP_USER_ID,
608 TaskFilterQuery {
609 status: Some(status),
610 project_id: None,
611 milestone_id: None,
612 priority: None,
613 show_snoozed: false,
614 waiting_only: false,
615 offset: Some(0),
616 limit: Some(OFFERED),
617 sort_column: None,
618 sort_direction: None,
619 },
620 )
621 .map_err(|error| RouteError::internal(error.to_string()))?;
622 out.extend(tasks.into_iter().filter(|task| Some(task.id) != running));
623 }
624 Ok(out)
625 }
626
627 /// What the Timer screen offers to track, and whether anything is in the way.
628 struct Offered {
629 tasks: Vec<Task>,
630 /// Whether a timer is already running, which is what disables every control
631 /// below.
632 busy: bool,
633 }
634
635 /// Read what can be tracked.
636 fn offering(state: &AppState, running: Option<&Running>) -> Result<Offered, RouteError> {
637 let held = running.map(|running| running.session.task_id);
638 Ok(Offered {
639 tasks: offered(state, held)?,
640 busy: held.is_some(),
641 })
642 }
643
644 /// The row's one trailing fact: the project, and the estimate and the tracked
645 /// total when the task carries them.
646 ///
647 /// One string rather than three settings because `meta` sets rather than
648 /// appends, so three of them left only the last: a task with tracked time never
649 /// showed its project.
650 fn task_meta(task: &Task) -> String {
651 let project = task.project_name.clone();
652 let estimate = task
653 .estimated_minutes
654 .map(|minutes| format!("{} est", spans(minutes)));
655 let tracked =
656 (task.actual_minutes > 0).then(|| format!("{} tracked", spans(task.actual_minutes)));
657 let said = [project, estimate, tracked]
658 .into_iter()
659 .flatten()
660 .collect::<Vec<_>>()
661 .join(" · ");
662 // What the row said when the project was its only fact, kept for the task
663 // that has none of the three. A leading dash in front of an estimate would
664 // be saying "no project" louder than the estimate it sits next to.
665 if said.is_empty() {
666 task.project_name_or_dash().to_owned()
667 } else {
668 said
669 }
670 }
671
672 /// Today's date, which is what a retroactive log opens on.
673 fn today() -> String {
674 chrono::Local::now().date_naive().to_string()
675 }
676
677 declare! {
678 /// One task, with what can be done to it.
679 ///
680 /// # What Focus carries that Track does not
681 ///
682 /// The split on the address, spent at the moment it is pressed. The shipped
683 /// row puts the same numbers in the button's `title`; here they are in the
684 /// label, because a description has no hint to put them in and a control
685 /// that does not say what it will do is worse than a long label.
686 ///
687 /// The row offered Track alone until migration 068. What was missing was
688 /// not anything the row could say: the session did not record which feature
689 /// started it, so the two controls would have written the same row and the
690 /// only difference between them would have been the sentence in the toast.
691 ///
692 /// Both are disabled rather than absent while something else is running:
693 /// the store allows one timer per user, so the control is real and
694 /// momentarily refused, and a row that lost its buttons would read as a
695 /// task that cannot be tracked at all.
696 ///
697 /// The project, the estimate and the tracked total are one `meta`, joined by
698 /// [`task_meta`]. `meta` sets rather than appends, so writing them as three
699 /// settings left only the last, which is what the hand-written row did and
700 /// what meant a task with tracked time never showed its project.
701 shape row_for(task: &Task, offered: &Offered, view: View) -> Row;
702
703 row &task.title {
704 meta task_meta(task);
705
706 for marker in super::Availability::of(task).marker().into_iter() {
707 token marker;
708 }
709
710 act "Track"
711 to doing view.carry(Action::post("/timer/view/track"))
712 .with("task", task.id.to_string()) {
713 tone Success;
714 disabled when offered.busy;
715 }
716
717 act "Focus {view.work}m"
718 to doing view.carry(Action::post("/timer/view/focus"))
719 .with("task", task.id.to_string()) {
720 disabled when offered.busy;
721 }
722
723 // The log-time modal, as the control that opens it. See the module
724 // header for why it is not a modal here.
725 act "Log"
726 to doing view.carry(Action::post("/timer/view/log"))
727 .with("task", task.id.to_string()) {
728 field Number "minutes" "Minutes" {
729 within "1" "1440";
730 value "30";
731 required;
732 }
733 field Date "date" "Date" {
734 value today();
735 }
736 }
737 }
738 }
739
740 declare! {
741 /// What a timer can be started on, as a region.
742 shape choices(offered: &Offered, view: View) -> Slot;
743
744 region CHOICES as Pane {
745 empty "No pending or started tasks to track." when offered.tasks.is_empty();
746
747 list {
748 for task in offered.tasks.iter() {
749 include row_for(task, offered, view);
750 }
751 } unless offered.tasks.is_empty();
752 }
753 }
754
755 /// Whether the report is already over this window.
756 fn over_window(report: &Report, window: i64) -> bool {
757 window == report.view.days
758 }
759
760 /// The window a report chip offers.
761 fn windowed_view(view: View, window: i64) -> View {
762 View {
763 days: window,
764 ..view
765 }
766 }
767
768 /// The report, and the window it was read over.
769 struct Report {
770 read: TimeReport,
771 view: View,
772 /// Everything tracked in the window, which is what each bar is a part of.
773 ///
774 /// Finding 4 in the module header for why this and not the store's
775 /// `bar_percent`: a meter's total has to be a set the part is part of, and
776 /// "of the biggest project" is not one.
777 total: u32,
778 }
779
780 /// Read the report.
781 fn reported(state: &AppState, view: View) -> Result<Report, RouteError> {
782 let report = crate::commands::time_report(state, Some(view.days))
783 .map_err(|error| RouteError::internal(error.to_string()))?;
784 Ok(Report {
785 total: counted(report.tracked_minutes),
786 read: report,
787 view,
788 })
789 }
790
791 /// A count of minutes as a meter reads it. Never negative, never overflowing.
792 fn counted(minutes: i32) -> u32 {
793 u32::try_from(minutes.max(0)).unwrap_or(u32::MAX)
794 }
795
796 /// What the window came to, said once above the projects.
797 fn window_total(report: &Report) -> String {
798 format!(
799 "{} tracked in the last {} days. Estimates are lifetime totals over tasks that \
800 carry one.",
801 spans(report.read.tracked_minutes),
802 report.view.days
803 )
804 }
805
806 /// Whether anything in this project carries an estimate.
807 ///
808 /// An accuracy of none means nothing does, which is a different statement from
809 /// "estimated zero", and the JS says so outright rather than showing a
810 /// percentage of nothing.
811 fn has_accuracy(project: &TimeReportProject) -> bool {
812 project.estimate_accuracy_percent.is_some()
813 }
814
815 /// The project row's one trailing fact: what the window tracked, and the
816 /// estimate against the actual when the project carries estimates.
817 ///
818 /// Joined for the same reason [`task_meta`] is: two `meta` settings left only
819 /// the second, so a project with estimates lost its tracked total and the meter
820 /// beside it was the only thing still saying it.
821 fn project_meta(project: &TimeReportProject) -> String {
822 let against = has_accuracy(project).then(|| against_estimate(project));
823 [Some(spans(project.tracked_minutes)), against]
824 .into_iter()
825 .flatten()
826 .collect::<Vec<_>>()
827 .join(" · ")
828 }
829
830 /// The estimate against the actual, in words.
831 fn against_estimate(project: &TimeReportProject) -> String {
832 format!(
833 "{} est / {} actual",
834 spans(project.estimated_minutes),
835 spans(project.actual_minutes)
836 )
837 }
838
839 /// That accuracy, as the badge reads it.
840 fn accuracy(project: &TimeReportProject) -> String {
841 format!("{}%", project.estimate_accuracy_percent.unwrap_or(0))
842 }
843
844 /// Whether the project overran its estimates.
845 fn overran(project: &TimeReportProject) -> Tone {
846 if project
847 .estimate_accuracy_percent
848 .is_some_and(|percent| percent > 100)
849 {
850 Tone::Danger
851 } else {
852 Tone::Success
853 }
854 }
855
856 declare! {
857 /// Where the time went: tracked per project in the window, beside estimated
858 /// against actual for the same projects.
859 ///
860 /// The `fed_by` is re-declared on every answer, the panel's rule and for
861 /// the panel's reason: the swap replaces the element.
862 shape report(report: &Report) -> Slot;
863
864 region REPORT as Pane {
865 fed_by report.view.carry(Action::get("/timer/report"));
866
867 section "Where the time went";
868
869 for window in WINDOWS {
870 chip "{window}d"
871 to doing windowed_view(report.view, window).carry(Action::get("/timer/report")) {
872 latched over_window(report, window);
873 }
874 }
875
876 empty "Nothing tracked or estimated yet." when report.read.projects.is_empty();
877
878 text window_total(report) unless report.read.projects.is_empty();
879
880 list {
881 for project in report.read.projects.iter() {
882 row &project.name {
883 meta project_meta(project);
884 meter Meter::new(counted(project.tracked_minutes), report.total)
885 .label("minutes");
886
887 token Tag::badge(accuracy(project)).tone(overran(project))
888 when has_accuracy(project);
889 token Tag::badge("no estimates") unless has_accuracy(project);
890 }
891 }
892 } unless report.read.projects.is_empty();
893 }
894 }
895
896 /// This week's total across every project, which is what each bar is part of.
897 fn week(panel: &TimeSummaryPanel) -> u32 {
898 counted(
899 panel
900 .projects
901 .iter()
902 .map(|project| project.total_minutes)
903 .sum(),
904 )
905 }
906
907 /// Today's total, in words.
908 fn today_total(panel: &TimeSummaryPanel) -> String {
909 spans(panel.today_minutes)
910 }
911
912 declare! {
913 /// What the day view's tracked-time panel holds.
914 ///
915 /// The collapse the JS wires by hand is a disclosure holding one child,
916 /// open. The name goes on the placement rather than here: this shape answers
917 /// a `Slot`, and a `Slot` cannot carry the name of the control that reveals
918 /// it (quasicoherent `2cdc6761`). See `summary_panel`, which places it with
919 /// `framed`.
920 shape summary_body(panel: &TimeSummaryPanel) -> Slot;
921
922 region "time-summary-body" as Group {
923 stats [] {
924 figure Figure::new(today_total(panel), "today");
925 }
926
927 section "This week" unless panel.projects.is_empty();
928
929 list {
930 for project in panel.projects.iter() {
931 row &project.name {
932 meta spans(project.total_minutes);
933 meter Meter::new(counted(project.total_minutes), week(panel))
934 .label("minutes");
935 }
936 }
937 } unless panel.projects.is_empty();
938 }
939 }
940
941 declare! {
942 /// Today's tracked total and this week's per-project split, as the day view
943 /// draws it.
944 ///
945 /// `time-summary.js`, which is the report's smaller sibling: the same shape
946 /// over a fixed window the app computes rather than one the reader picks.
947 ///
948 /// Public because the day view places it: it is this module's region drawn
949 /// on [`super::day_planning`]'s screen, the way `time-summary.js` renders
950 /// into the day sidebar's container. The read that feeds it is
951 /// [`tracked`], which that screen makes for itself.
952 pub(super) shape summary_panel(panel: &TimeSummaryPanel) -> Slot;
953
954 region SUMMARY as Group {
955 fed_by Action::get("/timer/summary");
956 showing_at_most_one Some(0);
957
958 framed "Time tracked" include summary_body(panel);
959 }
960 }
961
962 /// What the day view's tracked-time panel is drawn from.
963 pub(super) fn tracked(state: &AppState) -> Result<TimeSummaryPanel, RouteError> {
964 crate::commands::time_summary_panel(state)
965 .map_err(|error| RouteError::internal(error.to_string()))
966 }
967
968 declare! {
969 /// The whole screen.
970 shape screen(
971 running: Option<&Running>,
972 offered: &Offered,
973 window: &Report,
974 view: View
975 ) -> Screen;
976
977 screen list_detail "Timer" false {
978 at_place super::shell::TIMER;
979
980 region "timer-band" as Band {
981 page "Timer";
982 }
983
984 include session(running, view);
985 include modes(view);
986 include choices(offered, view);
987 include report(window);
988 }
989 }
990
991 /// Everything the Timer screen draws, read once.
992 fn read(state: &AppState, view: View) -> Result<(Option<Running>, Offered, Report), RouteError> {
993 let running = running(state)?;
994 let offered = offering(state, running.as_ref())?;
995 let report = reported(state, view)?;
996 Ok((running, offered, report))
997 }
998
999 /// The whole screen, as an answer.
1000 fn view(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1001 let view = View::of(&request);
1002 let (running, offered, window) = read(state, view)?;
1003 Ok(screen(running.as_ref(), &offered, &window, view).into())
1004 }
1005
1006 /// The three regions a write on this screen moves, together.
1007 ///
1008 /// Together because they move together: starting a timer fills the band and
1009 /// disables every Track control, and logging time changes both the row's
1010 /// tracked total and the report under it.
1011 fn answer(state: &AppState, view: View) -> Result<Response, RouteError> {
1012 let (running, offered, window) = read(state, view)?;
1013 Ok(
1014 Response::fragment(SESSION, Node::Region(session(running.as_ref(), view)))
1015 .also(CHOICES, Node::Region(choices(&offered, view)))
1016 .also(REPORT, Node::Region(report(&window))),
1017 )
1018 }
1019
1020 /// The report alone, which is what changing the window replaces.
1021 ///
1022 /// Answered with the screen's address rather than the region's, so a reload
1023 /// lands on the same window. The window is a fact about the view and the region
1024 /// is where it happens to be drawn.
1025 fn windowed(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1026 let view = View::of(&request);
1027 Ok(Response::fragment(REPORT, Node::Region(report(&reported(state, view)?))).at(view.url()))
1028 }
1029
1030 /// The day view's panel, as an answer.
1031 fn summarised(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
1032 Ok(Response::fragment(
1033 SUMMARY,
1034 Node::Region(summary_panel(&tracked(state)?)),
1035 ))
1036 }
1037
1038 /// Start timing a task from the screen.
1039 ///
1040 /// The panel's [`start`] refused a second timer with a complaint rather than a
1041 /// fault, and this does the same for the same reason.
1042 fn track(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1043 let id = asked_for(&request)?;
1044 let view = View::of(&request);
1045 match state.tasks.start_timer(id, DESKTOP_USER_ID) {
1046 Ok(_) => Ok(answer(state, view)?.toast(Tone::Success, "Timer started.")),
1047 Err(error) => Ok(answer(state, view)?.toast(Tone::Warning, error.to_string())),
1048 }
1049 }
1050
1051 /// Start a focus countdown on a task, from the screen.
1052 ///
1053 /// The split it runs to is the one the address carries at the moment it is
1054 /// pressed, which is what "carrying the split it was started under" means: the
1055 /// session records the instant, so changing the split afterwards moves the next
1056 /// countdown rather than this one.
1057 ///
1058 /// The full-screen overlay `focus-timer.js` draws is still not described, and
1059 /// that is quasicoherent `858be2a6` rather than this route: an overlay is
1060 /// [`Outcome::Over`](quasi_router::Outcome::Over), and quasi-webview emits the
1061 /// container it retargets at only alongside a chrome binding. What this app
1062 /// draws instead is the band, which counts down to the same instant on every
1063 /// screen. That is less than the shipped overlay and it is not a toast with a
1064 /// different sentence in it.
1065 fn focus(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1066 let id = asked_for(&request)?;
1067 let view = View::of(&request);
1068 let ends_at = chrono::Utc::now() + chrono::Duration::minutes(view.work);
1069 match state
1070 .tasks
1071 .start_focus_session(id, DESKTOP_USER_ID, ends_at)
1072 {
1073 Ok(_) => Ok(answer(state, view)?.toast(
1074 Tone::Success,
1075 format!("Focus session started, {} minutes.", view.work),
1076 )),
1077 Err(error) => Ok(answer(state, view)?.toast(Tone::Warning, error.to_string())),
1078 }
1079 }
1080
1081 /// Stop the running timer, from the screen.
1082 fn halt(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1083 let view = View::of(&request);
1084 let running = state
1085 .tasks
1086 .get_active_timer(DESKTOP_USER_ID)
1087 .map_err(|error| RouteError::internal(error.to_string()))?;
1088
1089 let Some((session, _)) = running else {
1090 return answer(state, view);
1091 };
1092
1093 let stopped = state
1094 .tasks
1095 .stop_timer(session.task_id, DESKTOP_USER_ID)
1096 .map_err(|error| RouteError::internal(error.to_string()))?;
1097
1098 let recorded = stopped
1099 .and_then(|session| session.duration_minutes)
1100 .unwrap_or(0);
1101 Ok(answer(state, view)?.toast(Tone::Success, format!("Tracked {}", spans(recorded))))
1102 }
1103
1104 /// Throw the running timer away, from the screen.
1105 fn drop_it(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1106 let view = View::of(&request);
1107 let running = state
1108 .tasks
1109 .get_active_timer(DESKTOP_USER_ID)
1110 .map_err(|error| RouteError::internal(error.to_string()))?;
1111
1112 let Some((session, _)) = running else {
1113 return answer(state, view);
1114 };
1115
1116 state
1117 .tasks
1118 .discard_timer(session.task_id, DESKTOP_USER_ID)
1119 .map_err(|error| RouteError::internal(error.to_string()))?;
1120
1121 Ok(answer(state, view)?.toast(Tone::Info, "Timer discarded."))
1122 }
1123
1124 /// Record time that was never timed.
1125 ///
1126 /// The date is a day and the session wants an instant, so the day becomes noon
1127 /// UTC, which is `submitLogTime`'s own conversion and its reason: a day with no
1128 /// time in it lands in the same date bucket whichever side of UTC the reader is
1129 /// on. A missing date is today, which is what the shipped field opens on.
1130 fn log(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1131 let id = asked_for(&request)?;
1132 let view = View::of(&request);
1133
1134 let minutes = request
1135 .payload
1136 .get("minutes")
1137 .and_then(|raw| raw.parse::<i32>().ok())
1138 .ok_or_else(|| RouteError::not_found("no duration"))?;
1139 let minutes = goingson_core::PositiveMinutes::try_new(minutes)
1140 .map_err(|error| RouteError::not_found(error.to_string()))?;
1141
1142 let day = match request.payload.get("date") {
1143 Some(raw) => chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d")
1144 .map_err(|_| RouteError::not_found("not a date"))?,
1145 None => chrono::Local::now().date_naive(),
1146 };
1147 let at = chrono::Utc.from_utc_datetime(
1148 &day.and_hms_opt(12, 0, 0)
1149 .ok_or_else(|| RouteError::internal("noon is always valid"))?,
1150 );
1151
1152 let session = state
1153 .tasks
1154 .log_manual_time(id, DESKTOP_USER_ID, minutes, at)
1155 .map_err(|error| RouteError::internal(error.to_string()))?;
1156
1157 Ok(answer(state, view)?.toast(
1158 Tone::Success,
1159 format!("Logged {}", spans(session.duration_minutes.unwrap_or(0))),
1160 ))
1161 }
1162
1163 /// The panel's routes, and the screen's.
1164 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1165 router
1166 .get("/timer", view)
1167 .get("/timer/panel", showing)
1168 .get("/timer/report", windowed)
1169 .get("/timer/summary", summarised)
1170 .post("/timer/start", start)
1171 .post("/timer/stop", stop)
1172 .post("/timer/discard", discard)
1173 .post("/timer/view/track", track)
1174 .post("/timer/view/focus", focus)
1175 .post("/timer/view/stop", halt)
1176 .post("/timer/view/discard", drop_it)
1177 .post("/timer/view/log", log)
1178 }
1179