| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
+ |
}
|