Compare commits
10
Commits
255dcd1392
...
253d72221b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253d72221b | ||
|
|
ae9e1b1f08 | ||
|
|
49e14b820a | ||
|
|
fe5e2d9a7f | ||
|
|
9257181a77 | ||
|
|
38f14fa453 | ||
|
|
c21b720eea | ||
|
|
235c4f0ad5 | ||
|
|
89c5279f52 | ||
|
|
57ea9403b6 |
No files matched your search
@@ -6,3 +6,11 @@ rustflags = ["-C", "target-feature=+crt-static"]
|
||||
[alias]
|
||||
# `cargo lint`: the strict check to run before committing.
|
||||
lint = "clippy --all-targets -- -D warnings"
|
||||
# `cargo linux`: build a Linux binary from any OS (needs, once:
|
||||
# `rustup target add x86_64-unknown-linux-musl`). It's fully static, so the
|
||||
# same file runs on Void (glibc or musl), Fedora and other distributions:
|
||||
# target/x86_64-unknown-linux-musl/release/skyshift
|
||||
linux = [
|
||||
"build", "--release", "--target", "x86_64-unknown-linux-musl",
|
||||
"--config", "target.x86_64-unknown-linux-musl.linker = 'rust-lld'",
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
# Keep LF line endings everywhere (Windows and Linux share this repo).
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,126 @@
|
||||
# skyshift
|
||||
|
||||
Track your work shifts from the terminal: start, pause, end the day
|
||||
(even when the day runs past midnight), copy totals into your timesheet,
|
||||
export reports, and see your work patterns.
|
||||
|
||||
Runs on Windows, Linux and macOS. Keyboard first, with vim-style keys, and
|
||||
the mouse works too.
|
||||
|
||||
## Build and run
|
||||
|
||||
You need Rust 1.89 or newer ([rustup.rs](https://rustup.rs), or your
|
||||
distribution's `rust` and `cargo` packages).
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The program is `target/release/skyshift` (`skyshift.exe` on Windows): copy
|
||||
it to a folder on your `PATH`.
|
||||
|
||||
- **Windows**: nothing else to install. The `.exe` carries its own C runtime
|
||||
and runs on any Windows 10 or 11.
|
||||
- **Void Linux**: `sudo xbps-install -S rust cargo`, then build as above.
|
||||
- **Fedora**: `sudo dnf install rust cargo`, then build as above.
|
||||
|
||||
On Linux no system library is needed: the clipboard works on both X11 and
|
||||
Wayland. If copying doesn't work in your setup, set `clipboard = "command"`
|
||||
and `clipboard_command = "wl-copy"` (or `"xclip -selection clipboard"`)
|
||||
in the settings.
|
||||
|
||||
## Everyday keys
|
||||
|
||||
| Keys | What they do |
|
||||
|------------------|----------------------------------------------------------|
|
||||
| `s` `p` `e` | start, pause, end the work day |
|
||||
| `<Space><Space>` | start / pause |
|
||||
| `<Space>p` | "I actually paused at 17:30" (also `<Space>s`, `<Space>e`) |
|
||||
| `Ctrl-x` `Ctrl-a`| the running session started 5 minutes earlier / later |
|
||||
| `yy` `yw` | copy the day / the week for your timesheet |
|
||||
| `<Space>c` | copy in another format, with a preview |
|
||||
| `gh` `gd` `gi` `gs` | Tracker, Days, Insights, Settings (also `gt`, `Tab`) |
|
||||
| `u` `Ctrl-r` | undo, redo: mistakes cost nothing |
|
||||
| `b` (`zb`) | take a pomodoro break, and come back from it |
|
||||
| `zz` | snooze the break suggestion |
|
||||
| `:` | command line: `:pause 17:30`, `:export csv lastmonth`, `:set time_format=12h` |
|
||||
| `?` | every key, for the screen you're on |
|
||||
| `q` | quit (a running shift keeps running) |
|
||||
|
||||
After the first key of a sequence (`g`, `y`, `<Space>`...), a popup lists
|
||||
what can follow (like which-key in Neovim). It can be turned off in the
|
||||
settings, or with `<Space>w`.
|
||||
|
||||
## How work days work
|
||||
|
||||
- **Start** opens a work day if none is open. The day keeps its date until
|
||||
you press **End day**, so a shift from 22:00 to 04:00 counts for the day
|
||||
it started.
|
||||
- Forgot **End day**? If a break lasts longer than 8 hours (a setting), the
|
||||
next Start opens a new work day by itself.
|
||||
- Times you type (`22:30`, `2230`, `22h30`, `10:30pm`, `10p`) are read in
|
||||
both 12h and 24h styles. An end time before the start time means the next
|
||||
day.
|
||||
- Pomodoro breaks (`b` on the Tracker, `zb` anywhere) are **paid** by
|
||||
default: the shift keeps running, so the break stays in your worked
|
||||
time. Turn off `paid_breaks` in the settings to make `b` pause the shift
|
||||
instead. `p` and `e` always stop the clock (lunch, end of day).
|
||||
- To add work to a day you didn't track, press `o` and change the **Work
|
||||
day** field: `2026-09-14`, `09-14`, `yesterday`, `-3` (3 days ago) or a
|
||||
weekday like `mon` all work.
|
||||
- Times are recorded to the minute, so what you see always adds up.
|
||||
|
||||
## Your files
|
||||
|
||||
Run `skyshift --paths` to see where they are.
|
||||
|
||||
- **`config.toml`**: every setting, each with a comment explaining it.
|
||||
Change settings in the app (Settings tab, `za` shows the expert ones) or
|
||||
in the file.
|
||||
- **`shifts.toml`**: your work, in plain text. You can edit it by hand;
|
||||
skyshift reloads it when it changes, and won't overwrite it while it has
|
||||
a mistake in it. A copy is saved once a day in `backups/`.
|
||||
- **Exports** (CSV, JSON, Markdown) go to your Documents folder, or the
|
||||
folder set in the settings.
|
||||
|
||||
To keep everything together, for example on a USB stick or in a synced
|
||||
folder: `skyshift --config D:\skyshift\config.toml` with
|
||||
`data_file = "shifts.toml"` in that config.
|
||||
|
||||
## How the code is organized
|
||||
|
||||
Every file starts with a comment explaining what it's for.
|
||||
|
||||
| Folder / file | What's in it |
|
||||
|----------------|--------------------------------------------------------------|
|
||||
| `domain/` | the rules: segments, work days, the journal (no files, no UI) |
|
||||
| `time/` | the clock, 12h/24h text, typed times, date ranges |
|
||||
| `analysis/` | the numbers behind the Insights screen |
|
||||
| `export/` | timesheet text, the clipboard, CSV/JSON/Markdown reports |
|
||||
| `storage/` | config.toml, shifts.toml, backups, the lock |
|
||||
| `ui/` | keys (`keymap.rs`), screens, popups, widgets, colors |
|
||||
| `settings.rs` | every setting, described once in one table |
|
||||
| `reminders.rs` | the pomodoro rhythm |
|
||||
| `action.rs` | everything the screens can ask the app to do |
|
||||
| `app.rs` | the core: the loop, and the one place where data changes |
|
||||
|
||||
The flow is always the same: a key becomes a `Command` (`ui/keymap.rs`),
|
||||
the screen turns the command into an `Action`, and `App::apply` changes the
|
||||
data, saves it, and shows a message.
|
||||
|
||||
Common changes:
|
||||
|
||||
- **Change a key**: edit the tables in `src/ui/keymap.rs`. The help screen
|
||||
and the which-key popup follow automatically.
|
||||
- **Add a setting**: add a field to `Settings`, its default, and one entry
|
||||
in `FIELDS` (all in `src/settings.rs`). The Settings screen and
|
||||
config.toml follow automatically.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
cargo test # run the tests
|
||||
cargo lint # clippy, treating warnings as errors
|
||||
cargo fmt # format the code
|
||||
cargo test show_screens -- --ignored --nocapture # print every screen as text
|
||||
```
|
||||
@@ -41,6 +41,9 @@ pub enum Action {
|
||||
since: Timestamp,
|
||||
stopped: Option<Timestamp>,
|
||||
},
|
||||
/// Start a pomodoro break, or come back from one. With paid breaks the
|
||||
/// shift keeps running; without, it's the same as Pause / Start.
|
||||
TakeBreak,
|
||||
/// Silence the break suggestion for a while.
|
||||
Snooze,
|
||||
|
||||
|
||||
+44
-3
@@ -10,9 +10,21 @@ use super::{days_in, median};
|
||||
use crate::domain::WorkDay;
|
||||
use crate::time::DateRange;
|
||||
|
||||
/// Session lengths are counted in these buckets: (label, upper limit in minutes).
|
||||
pub const SESSION_BUCKETS: [(&str, i64); 6] =
|
||||
[("<15m", 15), ("15-30m", 30), ("30m-1h", 60), ("1-2h", 120), ("2-3h", 180), ("3h+", i64::MAX)];
|
||||
/// Session lengths are counted in these buckets: (label, upper limit in
|
||||
/// minutes). 25 minutes is a classic pomodoro. Change them freely: the
|
||||
/// Insights screen follows.
|
||||
pub const SESSION_BUCKETS: [(&str, i64); 10] = [
|
||||
("under 5m", 5),
|
||||
("5-15m", 15),
|
||||
("15-25m", 25),
|
||||
("25-45m", 45),
|
||||
("45m-1h", 60),
|
||||
("1h-1h30m", 90),
|
||||
("1h30m-2h", 120),
|
||||
("2-3h", 180),
|
||||
("3-4h", 240),
|
||||
("4h or more", i64::MAX),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Focus {
|
||||
@@ -73,3 +85,32 @@ pub fn focus(days: &[WorkDay], range: DateRange, zone: &TimeZone, long_session:
|
||||
past_midnight,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{Journal, Segment, ShiftState};
|
||||
use jiff::civil::date;
|
||||
|
||||
#[test]
|
||||
fn sessions_land_in_the_right_bucket() {
|
||||
// Sessions of 3m, 20m, exactly 25m, 50m and 5h, with 10m breaks.
|
||||
let lengths = [3, 20, 25, 50, 300];
|
||||
let mut start = date(2026, 9, 24).at(8, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp();
|
||||
let mut segments = Vec::new();
|
||||
for minutes in lengths {
|
||||
let end = start + SignedDuration::from_mins(minutes);
|
||||
segments.push(Segment::new(start, end).unwrap());
|
||||
start = end + SignedDuration::from_mins(10);
|
||||
}
|
||||
let journal = Journal::from_parts(vec![(date(2026, 9, 24), segments)], ShiftState::Idle).unwrap();
|
||||
let days = journal.snapshot(start);
|
||||
let range = DateRange::last_days(date(2026, 9, 24), 7);
|
||||
|
||||
let result = focus(&days, range, &TimeZone::UTC, SignedDuration::from_hours(2));
|
||||
// under 5m, 5-15m, 15-25m, 25-45m, 45m-1h, ..., 4h or more
|
||||
assert_eq!(result.session_counts, [1, 0, 1, 1, 1, 0, 0, 0, 0, 1]);
|
||||
assert_eq!(result.long_sessions, 1);
|
||||
assert_eq!(result.breaks, 4);
|
||||
}
|
||||
}
|
||||
+1003
File diff suppressed because it is too large.
Load diff
@@ -80,12 +80,13 @@ pub fn render(report: &Report) -> String {
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// The end time, marked "(+1)" when it falls on the next calendar day.
|
||||
/// The end time, marked "(+1)" when it falls on the next calendar day (or
|
||||
/// "(-1)" the day before).
|
||||
fn out_time(report: &Report, day: &WorkDay, end: Timestamp) -> String {
|
||||
let days_later = report.local(end).date().duration_since(day.date()).as_hours() / 24;
|
||||
match days_later {
|
||||
0 => report.time(end),
|
||||
later => format!("{} (+{later})", report.time(end)),
|
||||
later => format!("{} ({later:+})", report.time(end)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+121
-4
@@ -1,11 +1,23 @@
|
||||
//! skyshift: track your work shifts from the terminal.
|
||||
//!
|
||||
//! The real entry point comes last, once the app core exists. Until
|
||||
//! then this only declares the modules written so far.
|
||||
#![allow(dead_code)]
|
||||
//! Where things are (each file starts with a comment explaining it):
|
||||
//!
|
||||
//! | Folder / file | What's in it |
|
||||
//! |-----------------|------------------------------------------------------------|
|
||||
//! | `domain/` | the rules: segments, work days, the journal (no I/O, no UI) |
|
||||
//! | `time/` | the clock, 12h/24h text, typed times, date ranges |
|
||||
//! | `analysis/` | the numbers behind the Insights screen |
|
||||
//! | `export/` | timesheet text, the clipboard, CSV/JSON/Markdown reports |
|
||||
//! | `storage/` | config.toml, shifts.toml, backups, the lock |
|
||||
//! | `ui/` | keys (vim-style), screens, popups, widgets |
|
||||
//! | `settings.rs` | every setting, described once |
|
||||
//! | `reminders.rs` | the pomodoro rhythm |
|
||||
//! | `action.rs` | everything the screens can ask for |
|
||||
//! | `app.rs` | the core: the loop, and the one place where data changes |
|
||||
|
||||
mod action;
|
||||
mod analysis;
|
||||
mod app;
|
||||
mod domain;
|
||||
mod export;
|
||||
mod reminders;
|
||||
@@ -14,4 +26,109 @@ mod storage;
|
||||
mod time;
|
||||
mod ui;
|
||||
|
||||
fn main() {}
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
use app::App;
|
||||
use storage::backup::daily_backup;
|
||||
use storage::journal_file::JournalStore;
|
||||
use storage::lock::InstanceLock;
|
||||
use storage::paths::Paths;
|
||||
use storage::settings_file;
|
||||
use time::Clock;
|
||||
use time::clock::parse_zone;
|
||||
|
||||
const USAGE: &str = "\
|
||||
skyshift: track your work shifts from the terminal
|
||||
|
||||
Usage: skyshift [--config <file>]
|
||||
|
||||
Options:
|
||||
--config <file> use this settings file instead of the usual one
|
||||
--paths show where skyshift keeps its files, then quit
|
||||
-h, --help show this help
|
||||
-V, --version show the version
|
||||
|
||||
Inside skyshift, press ? to see every key.";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("skyshift: {error:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> anyhow::Result<()> {
|
||||
let options = Options::read(std::env::args().skip(1))?;
|
||||
if options.help {
|
||||
println!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
if options.version {
|
||||
println!("skyshift {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let paths = Paths::locate(options.config)?;
|
||||
let (settings, mut warnings) = settings_file::load(&paths.config_file)?;
|
||||
let data_file = paths.data_file(&settings);
|
||||
if options.paths {
|
||||
println!("settings {}", paths.config_file.display());
|
||||
println!("shifts {}", data_file.display());
|
||||
println!("exports {}", paths.export_folder(&settings).display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let zone = parse_zone(&settings.timezone).unwrap_or_else(|error| {
|
||||
warnings.push(format!("time zone: {error}"));
|
||||
TimeZone::system()
|
||||
});
|
||||
let clock = Clock::new(zone, settings.clock_adjustment, settings.punch_precision);
|
||||
|
||||
// Kept until skyshift exits: no second copy can use the same data file.
|
||||
let _lock = InstanceLock::acquire(&data_file)?;
|
||||
if let Err(error) = daily_backup(&data_file, clock.now().date(), settings.backups) {
|
||||
warnings.push(format!("today's backup failed: {error}"));
|
||||
}
|
||||
let (store, journal) = JournalStore::open(data_file, clock.zone())?;
|
||||
|
||||
let mut app = App::new(paths, settings, clock, store, journal, warnings);
|
||||
let mut terminal = ui::terminal::start(app.mouse_enabled()).context("can't set up the terminal")?;
|
||||
let result = app.run(&mut terminal);
|
||||
ui::terminal::stop();
|
||||
if let Some(note) = app.farewell() {
|
||||
println!("{note}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The command line options.
|
||||
#[derive(Debug, Default)]
|
||||
struct Options {
|
||||
config: Option<PathBuf>,
|
||||
paths: bool,
|
||||
help: bool,
|
||||
version: bool,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
fn read(mut args: impl Iterator<Item = String>) -> anyhow::Result<Options> {
|
||||
let mut options = Options::default();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--config" => options.config = Some(args.next().context("--config needs a file")?.into()),
|
||||
"--paths" => options.paths = true,
|
||||
"-h" | "--help" => options.help = true,
|
||||
"-V" | "--version" => options.version = true,
|
||||
other => bail!("unknown option \"{other}\"\n\n{USAGE}"),
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
+102
-8
@@ -8,11 +8,52 @@
|
||||
//! interruptions (shorter than the short break) don't reset it.
|
||||
//! - When a stretch reaches the focus length, a break is due: a short one,
|
||||
//! or a long one after every `long_break_every` completed stretches.
|
||||
//! - A break is either a pause (the shift stops) or a **paid break**
|
||||
//! ([`PaidBreaks`]): the shift keeps running, so the break stays in your
|
||||
//! worked time, but for the rhythm it counts as a break all the same.
|
||||
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use crate::domain::{Journal, ShiftState};
|
||||
|
||||
/// Pomodoro breaks taken without pausing the shift. They're only kept in
|
||||
/// memory: your worked time doesn't change, so the data file doesn't either.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PaidBreaks {
|
||||
/// Breaks that are over, as (start, end).
|
||||
taken: Vec<(Timestamp, Timestamp)>,
|
||||
/// When the break in progress started.
|
||||
current: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl PaidBreaks {
|
||||
pub fn current(&self) -> Option<Timestamp> {
|
||||
self.current
|
||||
}
|
||||
|
||||
pub fn start(&mut self, at: Timestamp) {
|
||||
self.current.get_or_insert(at);
|
||||
}
|
||||
|
||||
/// Ends the break in progress, if there is one.
|
||||
pub fn finish(&mut self, at: Timestamp) {
|
||||
if let Some(start) = self.current.take()
|
||||
&& at > start
|
||||
{
|
||||
self.taken.push((start, at));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
*self = PaidBreaks::default();
|
||||
}
|
||||
|
||||
/// Every break, the one in progress lasting until `now`.
|
||||
fn spans(&self, now: Timestamp) -> impl Iterator<Item = (Timestamp, Timestamp)> + '_ {
|
||||
self.taken.iter().copied().chain(self.current.map(|start| (start, now.max(start))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PomodoroSettings {
|
||||
pub enabled: bool,
|
||||
@@ -30,7 +71,8 @@ pub enum Phase {
|
||||
/// Working. `started` is when the current focus stretch began.
|
||||
Focus { started: Timestamp, focused: SignedDuration, goal: SignedDuration },
|
||||
/// On a break that began at `started`. `goal` is the suggested length.
|
||||
Break { started: Timestamp, taken: SignedDuration, goal: SignedDuration, long: bool },
|
||||
/// `paid` means the shift is still running (a paid break).
|
||||
Break { started: Timestamp, taken: SignedDuration, goal: SignedDuration, long: bool, paid: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -62,7 +104,12 @@ impl Pomodoro {
|
||||
}
|
||||
|
||||
/// The rhythm of the open work day, as of `now`.
|
||||
pub fn pomodoro(journal: &Journal, now: Timestamp, settings: &PomodoroSettings) -> Pomodoro {
|
||||
pub fn pomodoro(
|
||||
journal: &Journal,
|
||||
paid_breaks: &PaidBreaks,
|
||||
now: Timestamp,
|
||||
settings: &PomodoroSettings,
|
||||
) -> Pomodoro {
|
||||
let mut result =
|
||||
Pomodoro { phase: Phase::Off, completed: 0, long_break_every: settings.long_break_every };
|
||||
let Some(open_day) = journal.state().open_day() else { return result };
|
||||
@@ -70,14 +117,20 @@ pub fn pomodoro(journal: &Journal, now: Timestamp, settings: &PomodoroSettings)
|
||||
return result;
|
||||
}
|
||||
|
||||
// The open day's segments, with the running session up to now.
|
||||
// The open day's segments, with the running session up to now...
|
||||
let mut pieces: Vec<(Timestamp, Timestamp)> = journal
|
||||
.day(open_day)
|
||||
.map(|day| day.segments().iter().map(|segment| (segment.start(), segment.end())).collect())
|
||||
.unwrap_or_default();
|
||||
let working = journal.state().is_working();
|
||||
if let ShiftState::Working { since, .. } = journal.state() {
|
||||
pieces.push((since, now.max(since)));
|
||||
}
|
||||
// ...with paid breaks cut out: for the rhythm, they're breaks too.
|
||||
let on_paid_break = working && paid_breaks.current().is_some();
|
||||
for (break_start, break_end) in paid_breaks.spans(now) {
|
||||
pieces = pieces.into_iter().flat_map(|piece| cut(piece, break_start, break_end)).collect();
|
||||
}
|
||||
|
||||
// Join pieces into focus stretches: a gap shorter than a short break
|
||||
// is an interruption, not a break.
|
||||
@@ -94,7 +147,7 @@ pub fn pomodoro(journal: &Journal, now: Timestamp, settings: &PomodoroSettings)
|
||||
result.completed = stretches.iter().filter(|stretch| stretch.worked >= settings.focus).count() as u32;
|
||||
let Some(last) = stretches.last() else { return result };
|
||||
|
||||
result.phase = if journal.state().is_working() {
|
||||
result.phase = if working && !on_paid_break {
|
||||
Phase::Focus { started: last.start, focused: last.worked, goal: settings.focus }
|
||||
} else {
|
||||
// The break that follows a completed stretch is long every
|
||||
@@ -108,11 +161,24 @@ pub fn pomodoro(journal: &Journal, now: Timestamp, settings: &PomodoroSettings)
|
||||
taken: now.duration_since(last.end),
|
||||
goal: if long { settings.long_break } else { settings.short_break },
|
||||
long,
|
||||
paid: on_paid_break,
|
||||
}
|
||||
};
|
||||
result
|
||||
}
|
||||
|
||||
/// What's left of the span `piece` once `start..end` is taken out of it:
|
||||
/// nothing, the piece itself, or the parts before and after.
|
||||
fn cut(piece: (Timestamp, Timestamp), start: Timestamp, end: Timestamp) -> Vec<(Timestamp, Timestamp)> {
|
||||
let (piece_start, piece_end) = piece;
|
||||
if end <= piece_start || start >= piece_end {
|
||||
return vec![piece];
|
||||
}
|
||||
let before = (piece_start, start.min(piece_end));
|
||||
let after = (end.max(piece_start), piece_end);
|
||||
[before, after].into_iter().filter(|(from, to)| to > from).collect()
|
||||
}
|
||||
|
||||
struct Stretch {
|
||||
start: Timestamp,
|
||||
end: Timestamp,
|
||||
@@ -145,7 +211,7 @@ mod tests {
|
||||
journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap();
|
||||
journal.pause(at(9, 20).timestamp()).unwrap();
|
||||
journal.start(&at(9, 22), NEW_DAY_AFTER).unwrap(); // a 2 minute interruption
|
||||
let rhythm = pomodoro(&journal, at(9, 27).timestamp(), &SETTINGS);
|
||||
let rhythm = pomodoro(&journal, &PaidBreaks::default(), at(9, 27).timestamp(), &SETTINGS);
|
||||
assert!(rhythm.break_due());
|
||||
assert_eq!(
|
||||
rhythm.phase,
|
||||
@@ -162,14 +228,42 @@ mod tests {
|
||||
let mut journal = Journal::default();
|
||||
journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap();
|
||||
journal.pause(at(9, 25).timestamp()).unwrap();
|
||||
let first_break = pomodoro(&journal, at(9, 26).timestamp(), &SETTINGS);
|
||||
assert!(matches!(first_break.phase, Phase::Break { long: false, .. }));
|
||||
let none = PaidBreaks::default();
|
||||
let first_break = pomodoro(&journal, &none, at(9, 26).timestamp(), &SETTINGS);
|
||||
assert!(matches!(first_break.phase, Phase::Break { long: false, paid: false, .. }));
|
||||
|
||||
journal.start(&at(9, 30), NEW_DAY_AFTER).unwrap();
|
||||
journal.pause(at(9, 55).timestamp()).unwrap();
|
||||
let second_break = pomodoro(&journal, at(10, 15).timestamp(), &SETTINGS);
|
||||
let second_break = pomodoro(&journal, &none, at(10, 15).timestamp(), &SETTINGS);
|
||||
assert_eq!(second_break.completed, 2);
|
||||
assert!(matches!(second_break.phase, Phase::Break { long: true, .. }));
|
||||
assert!(second_break.break_over());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paid_break_is_a_break_while_the_shift_keeps_running() {
|
||||
let mut journal = Journal::default();
|
||||
journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap();
|
||||
let mut paid = PaidBreaks::default();
|
||||
paid.start(at(9, 25).timestamp());
|
||||
|
||||
let on_break = pomodoro(&journal, &paid, at(9, 28).timestamp(), &SETTINGS);
|
||||
assert_eq!(
|
||||
on_break.phase,
|
||||
Phase::Break {
|
||||
started: at(9, 25).timestamp(),
|
||||
taken: SignedDuration::from_mins(3),
|
||||
goal: SETTINGS.short_break,
|
||||
long: false,
|
||||
paid: true
|
||||
}
|
||||
);
|
||||
|
||||
paid.finish(at(9, 30).timestamp());
|
||||
let back = pomodoro(&journal, &paid, at(9, 40).timestamp(), &SETTINGS);
|
||||
assert_eq!(back.completed, 1);
|
||||
assert!(matches!(back.phase, Phase::Focus { started, .. } if started == at(9, 30).timestamp()));
|
||||
// The shift itself never stopped: 40 minutes worked.
|
||||
assert_eq!(journal.running_for(at(9, 40).timestamp()), SignedDuration::from_mins(40));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub struct Settings {
|
||||
pub export_folder: String,
|
||||
// Reminders
|
||||
pub pomodoro: bool,
|
||||
pub paid_breaks: bool,
|
||||
pub focus_length: SignedDuration,
|
||||
pub short_break: SignedDuration,
|
||||
pub long_break: SignedDuration,
|
||||
@@ -97,6 +98,7 @@ impl Default for Settings {
|
||||
export_format: ExportFormat::Csv,
|
||||
export_folder: String::new(),
|
||||
pomodoro: true,
|
||||
paid_breaks: true,
|
||||
focus_length: SignedDuration::from_mins(25),
|
||||
short_break: SignedDuration::from_mins(5),
|
||||
long_break: SignedDuration::from_mins(15),
|
||||
@@ -538,6 +540,19 @@ pub static FIELDS: &[Field] = &[
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "paid_breaks",
|
||||
label: "Paid breaks",
|
||||
help: "on: b takes a pomodoro break without pausing the shift, so breaks stay in your worked time (for jobs that pay breaks). off: b pauses the shift like p. Either way, p and e always stop the clock.",
|
||||
expert: false,
|
||||
kind: Kind::Toggle,
|
||||
get: |s| s.paid_breaks.to_string(),
|
||||
set: |s, text| {
|
||||
s.paid_breaks = toggle(text)?;
|
||||
Ok(())
|
||||
},
|
||||
},
|
||||
Field {
|
||||
section: "reminders",
|
||||
key: "focus_length",
|
||||
|
||||
+62
-2
@@ -6,8 +6,8 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use jiff::civil::{Date, Time};
|
||||
use jiff::{SignedDuration, Zoned};
|
||||
use jiff::civil::{Date, Time, Weekday};
|
||||
use jiff::{SignedDuration, ToSpan, Zoned};
|
||||
|
||||
/// How clock times are written.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -284,9 +284,69 @@ fn split_am_pm(text: &str) -> (&str, Option<AmPm>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a date typed by the user, with shortcuts for recent days:
|
||||
///
|
||||
/// `2026-09-14`, `09-14` or `9-14` (this year), `today`, `yesterday`,
|
||||
/// `-3` (3 days ago), or a weekday like `mon` or `friday` (the most recent
|
||||
/// one, today included).
|
||||
pub fn parse_date(input: &str, today: Date) -> Result<Date, String> {
|
||||
let text = input.trim().to_ascii_lowercase();
|
||||
let error = || format!("\"{}\" isn't a date; try 2026-09-14, 09-14, yesterday, -3 or mon", input.trim());
|
||||
|
||||
match text.as_str() {
|
||||
"today" => return Ok(today),
|
||||
"yesterday" => return Ok(today.saturating_sub(1.day())),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(days) = text.strip_prefix('-').and_then(|days| days.parse::<i64>().ok()) {
|
||||
return Ok(today.saturating_sub(days.days()));
|
||||
}
|
||||
// A weekday name, or its start ("mon", "thu"): the most recent one.
|
||||
const WEEKDAYS: [(&str, Weekday); 7] = [
|
||||
("monday", Weekday::Monday),
|
||||
("tuesday", Weekday::Tuesday),
|
||||
("wednesday", Weekday::Wednesday),
|
||||
("thursday", Weekday::Thursday),
|
||||
("friday", Weekday::Friday),
|
||||
("saturday", Weekday::Saturday),
|
||||
("sunday", Weekday::Sunday),
|
||||
];
|
||||
if text.len() >= 3
|
||||
&& let Some((_, weekday)) = WEEKDAYS.iter().find(|(name, _)| name.starts_with(&text))
|
||||
{
|
||||
return Ok(today.saturating_sub(today.weekday().since(*weekday).days()));
|
||||
}
|
||||
// "09-14" or "9-14": month and day of this year.
|
||||
let numbers: Vec<&str> = text.split('-').collect();
|
||||
if let [month, day] = numbers.as_slice() {
|
||||
let month: i8 = month.parse().map_err(|_| error())?;
|
||||
let day: i8 = day.parse().map_err(|_| error())?;
|
||||
return Date::new(today.year(), month, day).map_err(|_| error());
|
||||
}
|
||||
text.parse().map_err(|_| error())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jiff::civil::date;
|
||||
|
||||
#[test]
|
||||
fn reads_dates_and_shortcuts() {
|
||||
let today = date(2026, 9, 24); // a Thursday
|
||||
assert_eq!(parse_date("2026-09-14", today), Ok(date(2026, 9, 14)));
|
||||
assert_eq!(parse_date("09-14", today), Ok(date(2026, 9, 14)));
|
||||
assert_eq!(parse_date("9-4", today), Ok(date(2026, 9, 4)));
|
||||
assert_eq!(parse_date("Today", today), Ok(today));
|
||||
assert_eq!(parse_date("yesterday", today), Ok(date(2026, 9, 23)));
|
||||
assert_eq!(parse_date("-3", today), Ok(date(2026, 9, 21)));
|
||||
assert_eq!(parse_date("mon", today), Ok(date(2026, 9, 21)));
|
||||
assert_eq!(parse_date("thursday", today), Ok(today));
|
||||
assert_eq!(parse_date("fri", today), Ok(date(2026, 9, 18)));
|
||||
assert!(parse_date("th", today).is_err());
|
||||
assert!(parse_date("13-40", today).is_err());
|
||||
assert!(parse_date("someday", today).is_err());
|
||||
}
|
||||
|
||||
fn time(hour: i8, minute: i8) -> Time {
|
||||
Time::new(hour, minute, 0, 0).unwrap()
|
||||
|
||||
+4
-2
@@ -92,12 +92,14 @@ impl Context<'_> {
|
||||
self.fmt.time(self.local(moment).time())
|
||||
}
|
||||
|
||||
/// A clock time, marked "+1" when it falls the day after `day`.
|
||||
/// A clock time, marked "+1" when it falls the day after `day` (or "-1"
|
||||
/// the day before, which a backdated start can do).
|
||||
pub fn time_on(&self, moment: Timestamp, day: Date) -> String {
|
||||
let days_later = self.local(moment).date().duration_since(day).as_hours() / 24;
|
||||
match days_later {
|
||||
0 => self.time(moment),
|
||||
later => format!("{} +{later}", self.time(moment)),
|
||||
// `:+` always writes the sign: +1, -1.
|
||||
later => format!("{} {later:+}", self.time(moment)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ pub enum Command {
|
||||
EndDayAt,
|
||||
StartEarlier,
|
||||
StartLater,
|
||||
TakeBreak,
|
||||
Snooze,
|
||||
// Recorded work
|
||||
Edit,
|
||||
@@ -124,6 +125,7 @@ pub const GLOBAL: &[Binding] = &[
|
||||
bind("<Tab>", C::NextScreen, "next screen"),
|
||||
bind("<S-Tab>", C::PreviousScreen, "previous screen"),
|
||||
bind("zz", C::Snooze, "snooze the break reminder"),
|
||||
bind("zb", C::TakeBreak, "take a pomodoro break / back from it"),
|
||||
bind("<Space><Space>", C::Toggle, "start / pause"),
|
||||
bind("<Space>s", C::StartAt, "start at an earlier time..."),
|
||||
bind("<Space>p", C::PauseAt, "pause at an earlier time..."),
|
||||
@@ -152,6 +154,7 @@ pub const LIST: &[Binding] = &[
|
||||
];
|
||||
|
||||
pub const TRACKER: &[Binding] = &[
|
||||
bind("b", C::TakeBreak, "take a pomodoro break / back from it"),
|
||||
bind("<CR>", C::Edit, "fix the session in progress"),
|
||||
bind("i", C::Edit, "fix the session in progress"),
|
||||
bind("o", C::AddAfter, "add forgotten work to today"),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
pub mod component;
|
||||
pub mod keymap;
|
||||
pub mod popups;
|
||||
pub mod screens;
|
||||
pub mod terminal;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -35,6 +35,7 @@ pub const COMMANDS: &[(&str, &str)] = &[
|
||||
(":set name :set noname", "turn an on/off setting on or off (name! flips it)"),
|
||||
(":undo :redo", "undo / redo"),
|
||||
(":snooze", "snooze the break reminder"),
|
||||
(":break", "take a pomodoro break / back from it"),
|
||||
(":tracker :days :insights :settings", "go to a screen"),
|
||||
(":help", "all keys"),
|
||||
(":q", "quit"),
|
||||
@@ -125,6 +126,7 @@ pub fn parse(text: &str, ctx: &Context) -> Result<Action, String> {
|
||||
"undo" | "u" => Ok(Action::Undo),
|
||||
"redo" => Ok(Action::Redo),
|
||||
"snooze" => Ok(Action::Snooze),
|
||||
"break" => Ok(Action::TakeBreak),
|
||||
"tracker" => Ok(Action::GoTo(Tab::Tracker)),
|
||||
"days" => Ok(Action::GoTo(Tab::Days)),
|
||||
"insights" => Ok(Action::GoTo(Tab::Insights)),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! "22:00 to 01:30" visibly ends the next day.
|
||||
|
||||
use jiff::civil::Date;
|
||||
use jiff::{Timestamp, Zoned};
|
||||
use jiff::{Timestamp, ToSpan, Zoned};
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::KeyEvent;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -16,7 +16,7 @@ use ratatui::widgets::Paragraph;
|
||||
use super::{field_line, message_line, window};
|
||||
use crate::action::Action;
|
||||
use crate::domain::{Segment, WorkDay};
|
||||
use crate::time::format::parse_time;
|
||||
use crate::time::format::{parse_date, parse_time};
|
||||
use crate::time::resolve::{nearest, next_after};
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::widgets::text_input::{InputEvent, TextInput};
|
||||
@@ -88,27 +88,44 @@ impl SegmentEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where typed start times are placed: close to this moment.
|
||||
fn reference(&self, ctx: &Context) -> Zoned {
|
||||
let day_edge = |day: Date, after: bool| {
|
||||
let work_day = ctx.day(day);
|
||||
let edge = if after {
|
||||
work_day.and_then(WorkDay::last_end)
|
||||
} else {
|
||||
work_day.and_then(WorkDay::first_start)
|
||||
};
|
||||
edge.map_or_else(|| ctx.noon(day), |moment| ctx.local(moment))
|
||||
};
|
||||
/// The work day the segment is filed under (the Work day field, which
|
||||
/// also takes shortcuts like "yesterday", "-3" or "mon").
|
||||
fn work_day(&self, ctx: &Context) -> Result<Date, String> {
|
||||
match self.target {
|
||||
Target::New { day, after } => day_edge(day, after),
|
||||
Target::Existing { original, .. } => ctx.local(original.start()),
|
||||
Target::Running { day, .. } => Ok(day),
|
||||
_ => parse_date(self.day.text(), ctx.now.date()).map_err(|error| format!("work day: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where typed start times are placed: close to this moment, on the
|
||||
/// work day `day` (the one in the Work day field).
|
||||
fn reference(&self, day: Date, ctx: &Context) -> Zoned {
|
||||
match self.target {
|
||||
// Next to the day's existing work, or around noon on an empty day.
|
||||
Target::New { after, .. } => {
|
||||
let work_day = ctx.day(day);
|
||||
let edge = if after {
|
||||
work_day.and_then(WorkDay::last_end)
|
||||
} else {
|
||||
work_day.and_then(WorkDay::first_start)
|
||||
};
|
||||
edge.map_or_else(|| ctx.noon(day), |moment| ctx.local(moment))
|
||||
}
|
||||
// Moving a segment to another work day moves its times with it,
|
||||
// keeping the same clock times.
|
||||
Target::Existing { day: original_day, original, .. } => {
|
||||
let moved_days = day.duration_since(original_day).as_hours() / 24;
|
||||
let start = ctx.local(original.start());
|
||||
start.checked_add(moved_days.days()).unwrap_or(start)
|
||||
}
|
||||
Target::Running { since, .. } => ctx.local(since),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&self, ctx: &Context) -> Result<Resolved, String> {
|
||||
let day = self.work_day(ctx)?;
|
||||
let start_time = parse_time(self.start.text()).map_err(|error| format!("start: {error}"))?;
|
||||
let start = nearest(start_time, &self.reference(ctx));
|
||||
let start = nearest(start_time, &self.reference(day, ctx));
|
||||
let end = match self.end.text().trim() {
|
||||
"" if self.is_running() => None,
|
||||
"" => return Err("end: when did it end?".into()),
|
||||
@@ -117,15 +134,6 @@ impl SegmentEditor {
|
||||
Some(next_after(end_time, &start))
|
||||
}
|
||||
};
|
||||
let day = match self.target {
|
||||
Target::Running { day, .. } => day,
|
||||
_ => self
|
||||
.day
|
||||
.text()
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "work day: write it like 2026-09-24".to_string())?,
|
||||
};
|
||||
Ok(Resolved { start, end, day })
|
||||
}
|
||||
|
||||
@@ -214,7 +222,11 @@ impl Component for SegmentEditor {
|
||||
),
|
||||
];
|
||||
if !self.is_running() {
|
||||
let note = Span::styled("the day it counts for", ctx.theme.muted());
|
||||
// Which date the field means ("yesterday" → Wed Sep 23).
|
||||
let note = match self.work_day(ctx) {
|
||||
Ok(day) => Span::styled(ctx.fmt.date_short(day), ctx.theme.accent()),
|
||||
Err(_) => Span::styled("a date, yesterday, -3, mon...", ctx.theme.muted()),
|
||||
};
|
||||
lines.push(field_line("Work day", &self.day, self.field == 2, self.inserting, note, ctx.theme));
|
||||
}
|
||||
lines.push(Line::default());
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
//! The Days screen: every work day (newest first, grouped by week) on the
|
||||
//! left; the selected day's details and segments on the right. This is
|
||||
//! where you fix, add, delete and copy.
|
||||
|
||||
use jiff::SignedDuration;
|
||||
use jiff::civil::Date;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Row, Table, TableState};
|
||||
|
||||
use super::copy_action;
|
||||
use crate::action::Action;
|
||||
use crate::domain::{Segment, ShiftState, WorkDay};
|
||||
use crate::time::{DateRange, DurationStyle};
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::popups::copy_menu::CopyMenu;
|
||||
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
|
||||
use crate::ui::widgets::timeline::{self, Timeline, window_for};
|
||||
use crate::ui::{Component, Context, popup};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Days,
|
||||
Segments,
|
||||
}
|
||||
|
||||
/// A row of the day list: a week heading, or a day.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Week { first: Date, worked: SignedDuration },
|
||||
Day(Date),
|
||||
}
|
||||
|
||||
pub struct Days {
|
||||
/// The selected work day (a date, so it survives changes to the list).
|
||||
selected: Option<Date>,
|
||||
/// The selected segment of that day.
|
||||
segment: usize,
|
||||
pane: Pane,
|
||||
list_state: ListState,
|
||||
table_state: TableState,
|
||||
/// What was drawn where, for the mouse and for half-page moves.
|
||||
entries: Vec<Entry>,
|
||||
list_area: Rect,
|
||||
table_area: Rect,
|
||||
}
|
||||
|
||||
impl Days {
|
||||
pub fn new() -> Days {
|
||||
Days {
|
||||
selected: None,
|
||||
segment: 0,
|
||||
pane: Pane::Days,
|
||||
list_state: ListState::default(),
|
||||
table_state: TableState::default(),
|
||||
entries: Vec::new(),
|
||||
list_area: Rect::default(),
|
||||
table_area: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, date: Date) {
|
||||
self.selected = Some(date);
|
||||
self.segment = 0;
|
||||
}
|
||||
|
||||
/// The work days to list, newest first.
|
||||
fn dates(ctx: &Context) -> Vec<Date> {
|
||||
let open_day = ctx.journal.state().open_day();
|
||||
ctx.days
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|day| !day.is_empty() || Some(day.date()) == open_day)
|
||||
.map(WorkDay::date)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rows of the list: each day, with a heading where a week starts.
|
||||
fn entries(dates: &[Date], ctx: &Context) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
for date in dates {
|
||||
let week = DateRange::week_of(*date, ctx.settings.week_start);
|
||||
let same_week = matches!(entries.last(), Some(Entry::Day(previous)) if week.contains(*previous));
|
||||
if !same_week {
|
||||
let worked =
|
||||
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
|
||||
entries.push(Entry::Week { first: week.first(), worked });
|
||||
}
|
||||
entries.push(Entry::Day(*date));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// The selected day, falling back to the newest one.
|
||||
fn current(&self, dates: &[Date]) -> Option<(usize, Date)> {
|
||||
let index = self.selected.and_then(|date| dates.iter().position(|d| *d == date)).unwrap_or(0);
|
||||
dates.get(index).map(|date| (index, *date))
|
||||
}
|
||||
|
||||
/// Moves the selection: `to` gets the current index and the last index.
|
||||
fn move_day(&mut self, dates: &[Date], to: impl FnOnce(usize, usize) -> usize) {
|
||||
if let Some((index, _)) = self.current(dates) {
|
||||
let last = dates.len() - 1;
|
||||
self.select(dates[to(index, last).min(last)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jumps to the first day of the week below (`down`) or above.
|
||||
fn move_week(&mut self, dates: &[Date], down: bool, ctx: &Context) {
|
||||
let Some((index, date)) = self.current(dates) else { return };
|
||||
let week_of = |date: Date| DateRange::week_of(date, ctx.settings.week_start);
|
||||
// The index of the first listed day of the week containing dates[at].
|
||||
let top_of_week = |at: usize| {
|
||||
let week = week_of(dates[at]);
|
||||
dates[..at].iter().rposition(|d| week_of(*d) != week).map_or(0, |i| i + 1)
|
||||
};
|
||||
let target = if down {
|
||||
let week = week_of(date);
|
||||
dates.iter().skip(index).position(|d| week_of(*d) != week).map(|offset| index + offset)
|
||||
} else if top_of_week(index) < index {
|
||||
Some(top_of_week(index))
|
||||
} else {
|
||||
index.checked_sub(1).map(top_of_week)
|
||||
};
|
||||
if let Some(target) = target {
|
||||
self.select(dates[target]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `segment` of `day` is the session in progress.
|
||||
fn is_running(day: Date, segment: &Segment, ctx: &Context) -> bool {
|
||||
matches!(ctx.journal.state(), ShiftState::Working { day: open, since } if open == day && segment.start() == since)
|
||||
}
|
||||
|
||||
fn edit(&self, day: &WorkDay, ctx: &Context) -> Action {
|
||||
let Some(segment) = day.segments().get(self.segment) else {
|
||||
return Action::Notify("This day has no segments (o adds one)".into());
|
||||
};
|
||||
let target = if Days::is_running(day.date(), segment, ctx) {
|
||||
Target::Running { day: day.date(), since: segment.start() }
|
||||
} else {
|
||||
Target::Existing { day: day.date(), index: self.segment, original: *segment }
|
||||
};
|
||||
popup(SegmentEditor::new(target, ctx))
|
||||
}
|
||||
|
||||
fn delete(&self, day: &WorkDay, ctx: &Context) -> Action {
|
||||
match day.segments().get(self.segment) {
|
||||
None => Action::Notify("Nothing to delete here".into()),
|
||||
Some(segment) if Days::is_running(day.date(), segment, ctx) => {
|
||||
Action::Notify("That's the session in progress: pause first (p), or fix it (i)".into())
|
||||
}
|
||||
Some(_) => Action::DeleteSegment { day: day.date(), index: self.segment },
|
||||
}
|
||||
}
|
||||
|
||||
fn render_list(&mut self, frame: &mut Frame, area: Rect, dates: &[Date], ctx: &Context) {
|
||||
let fmt = &ctx.fmt;
|
||||
self.entries = Days::entries(dates, ctx);
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Entry::Week { first, worked } => ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Week of {:<18}", fmt.date_short(*first)),
|
||||
ctx.theme.muted().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("{:>9}", fmt.duration(*worked)), ctx.theme.muted()),
|
||||
])),
|
||||
Entry::Day(date) => {
|
||||
let day = ctx.day(*date);
|
||||
let span = match (day.and_then(WorkDay::first_start), day.and_then(WorkDay::last_end)) {
|
||||
(Some(start), Some(end)) => format!("{}-{}", ctx.time(start), ctx.time(end)),
|
||||
_ => String::new(),
|
||||
};
|
||||
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
|
||||
ListItem::new(format!(
|
||||
" {:<11} {:<17} {:>8}",
|
||||
fmt.date_short(*date),
|
||||
span,
|
||||
fmt.duration(worked)
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let selected_row = self
|
||||
.current(dates)
|
||||
.and_then(|(_, date)| self.entries.iter().position(|entry| *entry == Entry::Day(date)));
|
||||
self.list_state.select(selected_row);
|
||||
|
||||
let focused = self.pane == Pane::Days;
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(focused))
|
||||
.title(Line::styled(" Work days ", ctx.theme.title())),
|
||||
)
|
||||
.highlight_style(ctx.theme.selected(focused));
|
||||
self.list_area = area;
|
||||
frame.render_stateful_widget(list, area, &mut self.list_state);
|
||||
}
|
||||
|
||||
fn render_details(&mut self, frame: &mut Frame, area: Rect, day: Option<&WorkDay>, ctx: &Context) {
|
||||
let focused = self.pane == Pane::Segments;
|
||||
let title = day.map_or("No work recorded yet".to_string(), |day| ctx.fmt.date_long(day.date()));
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(focused))
|
||||
.title(Line::styled(format!(" {title} "), ctx.theme.title()))
|
||||
.title_bottom(
|
||||
Line::styled(
|
||||
" i edit · o add · dd delete · yy copy · <Space>c copy as... ",
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
.right_aligned(),
|
||||
);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let Some(day) = day else {
|
||||
let hint = " Press s to start your first shift, or o to add work you already did.";
|
||||
frame.render_widget(Paragraph::new(Span::styled(hint, ctx.theme.muted())), inner);
|
||||
return;
|
||||
};
|
||||
|
||||
let [summary, _, timeline_area, _, table_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(timeline::HEIGHT),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let fmt = &ctx.fmt;
|
||||
let date = day.date();
|
||||
let bold = Style::new().add_modifier(Modifier::BOLD);
|
||||
let working_on_it =
|
||||
matches!(ctx.journal.state(), ShiftState::Working { day: open, .. } if open == date);
|
||||
let out = match day.last_end() {
|
||||
_ if working_on_it => "now".to_string(),
|
||||
Some(end) => ctx.time_on(end, date),
|
||||
None => String::new(),
|
||||
};
|
||||
let summary_lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" In ", ctx.theme.muted()),
|
||||
Span::styled(
|
||||
day.first_start().map(|start| ctx.time_on(start, date)).unwrap_or_default(),
|
||||
bold,
|
||||
),
|
||||
Span::styled(" Out ", ctx.theme.muted()),
|
||||
Span::styled(out, bold),
|
||||
Span::styled(" Break ", ctx.theme.muted()),
|
||||
Span::styled(fmt.duration(day.break_time()), bold),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Worked ", ctx.theme.muted()),
|
||||
Span::styled(fmt.duration(day.worked()), bold),
|
||||
Span::styled(
|
||||
format!(" {} h", fmt.duration_as(DurationStyle::Decimal, day.worked())),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(summary_lines), summary);
|
||||
|
||||
let pieces: Vec<_> = day.segments().iter().map(|segment| (segment.start(), segment.end())).collect();
|
||||
let state = ctx.journal.state();
|
||||
let now = (state.open_day() == Some(date)).then(|| ctx.now.timestamp());
|
||||
let running_since = match state {
|
||||
ShiftState::Working { day: open, since } if open == date => Some(since),
|
||||
_ => None,
|
||||
};
|
||||
let inset =
|
||||
Rect { x: timeline_area.x + 1, width: timeline_area.width.saturating_sub(2), ..timeline_area };
|
||||
frame.render_widget(
|
||||
Timeline {
|
||||
pieces: &pieces,
|
||||
running_since,
|
||||
now,
|
||||
window: window_for(&pieces, now, ctx.clock.zone(), 6),
|
||||
zone: ctx.clock.zone(),
|
||||
fmt,
|
||||
theme: ctx.theme,
|
||||
},
|
||||
inset,
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = day
|
||||
.segments()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, segment)| {
|
||||
let end = if Days::is_running(date, segment, ctx) {
|
||||
Span::styled("now ●", ctx.theme.accent())
|
||||
} else {
|
||||
Span::raw(ctx.time_on(segment.end(), date))
|
||||
};
|
||||
Row::new(vec![
|
||||
Span::raw(format!(" {}", index + 1)),
|
||||
Span::raw(ctx.time_on(segment.start(), date)),
|
||||
end,
|
||||
Span::raw(fmt.duration(segment.duration())),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
self.segment = self.segment.min(rows.len().saturating_sub(1));
|
||||
self.table_state.select((!rows.is_empty()).then_some(self.segment));
|
||||
let widths =
|
||||
[Constraint::Length(4), Constraint::Length(13), Constraint::Length(13), Constraint::Length(10)];
|
||||
let table = Table::new(rows, widths)
|
||||
.header(Row::new([" #", "Start", "End", "Length"]).style(ctx.theme.muted()))
|
||||
.row_highlight_style(if focused { ctx.theme.selected(true) } else { bold });
|
||||
self.table_area = table_area;
|
||||
frame.render_stateful_widget(table, table_area, &mut self.table_state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Days {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::DAYS, keymap::LIST]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let dates = Days::dates(ctx);
|
||||
let [list_area, details_area] =
|
||||
Layout::horizontal([Constraint::Length(44), Constraint::Fill(1)]).areas(area);
|
||||
self.render_list(frame, list_area, &dates, ctx);
|
||||
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
|
||||
self.render_details(frame, details_area, day, ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let dates = Days::dates(ctx);
|
||||
let steps = count.unwrap_or(1);
|
||||
let half_page = usize::from(self.list_area.height.saturating_sub(2) / 2).max(1);
|
||||
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
|
||||
let segments = day.map_or(0, |day| day.segments().len());
|
||||
|
||||
match (command, self.pane) {
|
||||
(Command::Down, Pane::Days) => self.move_day(&dates, |index, _| index + steps),
|
||||
(Command::Up, Pane::Days) => self.move_day(&dates, |index, _| index.saturating_sub(steps)),
|
||||
(Command::HalfPageDown, _) => self.move_day(&dates, |index, _| index + half_page),
|
||||
(Command::HalfPageUp, _) => self.move_day(&dates, |index, _| index.saturating_sub(half_page)),
|
||||
(Command::Top, Pane::Days) => self.move_day(&dates, |_, _| 0),
|
||||
(Command::Bottom, Pane::Days) => self.move_day(&dates, |_, last| last),
|
||||
(Command::Down, Pane::Segments) => {
|
||||
self.segment = (self.segment + steps).min(segments.saturating_sub(1))
|
||||
}
|
||||
(Command::Up, Pane::Segments) => self.segment = self.segment.saturating_sub(steps),
|
||||
(Command::Top, Pane::Segments) => self.segment = 0,
|
||||
(Command::Bottom, Pane::Segments) => self.segment = segments.saturating_sub(1),
|
||||
(Command::NextWeek, _) => self.move_week(&dates, true, ctx),
|
||||
(Command::PreviousWeek, _) => self.move_week(&dates, false, ctx),
|
||||
(Command::Right, _) if segments > 0 => self.pane = Pane::Segments,
|
||||
(Command::Left, _) => self.pane = Pane::Days,
|
||||
(Command::Edit, Pane::Days) if segments > 0 => self.pane = Pane::Segments,
|
||||
(Command::Edit, _) => return day.map(|day| self.edit(day, ctx)),
|
||||
(Command::Delete, Pane::Segments) => return day.map(|day| self.delete(day, ctx)),
|
||||
(Command::Delete, Pane::Days) => {
|
||||
return Some(Action::Notify("Choose a segment first (l), then dd deletes it".into()));
|
||||
}
|
||||
(Command::AddAfter | Command::AddBefore, _) => {
|
||||
let date = day.map_or(ctx.current_day(), WorkDay::date);
|
||||
let after = command == Command::AddAfter;
|
||||
return Some(popup(SegmentEditor::new(Target::New { day: date, after }, ctx)));
|
||||
}
|
||||
(Command::Copy(target, preset), _) => {
|
||||
return day.map(|day| copy_action(target, preset, day.date(), ctx));
|
||||
}
|
||||
(Command::CopyMenu, _) => return day.map(|day| popup(CopyMenu::new(day.date(), ctx))),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
|
||||
let position = Position::new(event.column, event.row);
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollDown => return self.command(Command::Down, None, ctx),
|
||||
MouseEventKind::ScrollUp => return self.command(Command::Up, None, ctx),
|
||||
MouseEventKind::Down(MouseButton::Left) => {}
|
||||
_ => return None,
|
||||
}
|
||||
// Rows start below the top border (and below the header row for the
|
||||
// table); both may be scrolled.
|
||||
if self.list_area.contains(position) {
|
||||
let row = usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
|
||||
if let Some(Entry::Day(date)) = self.entries.get(row) {
|
||||
self.select(*date);
|
||||
self.pane = Pane::Days;
|
||||
}
|
||||
} else if self.table_area.contains(position) {
|
||||
self.segment =
|
||||
usize::from(position.y.saturating_sub(self.table_area.y + 1)) + self.table_state.offset();
|
||||
self.pane = Pane::Segments;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! The Insights screen: four ways to look at your work.
|
||||
//!
|
||||
//! - Overview: this week and month against your targets, your balance,
|
||||
//! weekly totals, and when your days start and end.
|
||||
//! - Calendar: every day as a colored square, like a wall calendar.
|
||||
//! - Rhythm: which hours of which weekdays you actually work.
|
||||
//! - Focus: how long you stay on, and how you take breaks.
|
||||
//!
|
||||
//! These are patterns to notice, not grades: no streaks and no scores.
|
||||
|
||||
use jiff::civil::{Date, Weekday};
|
||||
use jiff::{SignedDuration, ToSpan};
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::symbols::Marker;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Axis, Bar, BarChart, Block, BorderType, Chart, Dataset, GraphType, Paragraph};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::analysis::calendar::calendar;
|
||||
use crate::analysis::focus::{SESSION_BUCKETS, focus};
|
||||
use crate::analysis::overview::{Targets, Totals, overview};
|
||||
use crate::analysis::rhythm::rhythm;
|
||||
use crate::analysis::{Period, days_in};
|
||||
use crate::domain::WorkDay;
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::widgets::heatmap::Heatmap;
|
||||
use crate::ui::widgets::progress_bar;
|
||||
use crate::ui::{Component, Context};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum View {
|
||||
Overview,
|
||||
Calendar,
|
||||
Rhythm,
|
||||
Focus,
|
||||
}
|
||||
|
||||
impl View {
|
||||
const ALL: [View; 4] = [View::Overview, View::Calendar, View::Rhythm, View::Focus];
|
||||
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
View::Overview => "Overview",
|
||||
View::Calendar => "Calendar",
|
||||
View::Rhythm => "Rhythm",
|
||||
View::Focus => "Focus",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Insights {
|
||||
view: View,
|
||||
period: Period,
|
||||
/// Where each view's tab was drawn, for the mouse.
|
||||
view_tabs: Vec<(View, Rect)>,
|
||||
}
|
||||
|
||||
impl Insights {
|
||||
pub fn new() -> Insights {
|
||||
Insights { view: View::Overview, period: Period::default(), view_tabs: Vec::new() }
|
||||
}
|
||||
|
||||
fn render_tabs(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
self.view_tabs.clear();
|
||||
let mut x = area.x + 1;
|
||||
for view in View::ALL {
|
||||
let label = format!(" {} ", view.title());
|
||||
let width = label.chars().count() as u16;
|
||||
let style = if view == self.view { ctx.theme.selected(true) } else { ctx.theme.muted() };
|
||||
let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area);
|
||||
frame.render_widget(Paragraph::new(Span::styled(label, style)), rect);
|
||||
self.view_tabs.push((view, rect));
|
||||
x += width + 1;
|
||||
}
|
||||
let period = Line::from(vec![
|
||||
Span::styled("period ", ctx.theme.muted()),
|
||||
Span::styled(self.period.label(), ctx.theme.title()),
|
||||
Span::styled(" < shorter · > longer · h/l views ", ctx.theme.muted()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(period).right_aligned(), area);
|
||||
}
|
||||
|
||||
fn render_overview(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let targets = Targets { daily: settings.daily_target, week_start: settings.week_start };
|
||||
let week_count = usize::from(area.width.saturating_sub(4) / 8).clamp(4, 12);
|
||||
let data = overview(ctx.days, today, range, targets, ctx.clock.zone(), week_count);
|
||||
|
||||
let [cards, weekly, spans] =
|
||||
Layout::vertical([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||
let [this_week, last_week, this_month, balance] =
|
||||
Layout::horizontal([Constraint::Fill(1); 4]).areas(cards);
|
||||
|
||||
let days_line = |totals: Totals| {
|
||||
let average = totals.average_day().map(|average| format!(" · {} a day", fmt.duration(average)));
|
||||
Line::styled(
|
||||
format!("{} days{}", totals.days_worked, average.unwrap_or_default()),
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
};
|
||||
let week_goal = if settings.weekly_target > SignedDuration::ZERO {
|
||||
let ratio = data.this_week.worked.as_secs_f64() / settings.weekly_target.as_secs_f64();
|
||||
let width = this_week.width.saturating_sub(10);
|
||||
let mut spans =
|
||||
progress_bar(width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()).to_vec();
|
||||
spans.push(Span::styled(format!(" {:.0}%", ratio * 100.0), ctx.theme.muted()));
|
||||
Line::from(spans)
|
||||
} else {
|
||||
days_line(data.this_week)
|
||||
};
|
||||
card(frame, this_week, "This week", fmt.duration(data.this_week.worked), week_goal, ctx);
|
||||
card(
|
||||
frame,
|
||||
last_week,
|
||||
"Last week",
|
||||
fmt.duration(data.last_week.worked),
|
||||
days_line(data.last_week),
|
||||
ctx,
|
||||
);
|
||||
card(
|
||||
frame,
|
||||
this_month,
|
||||
"This month",
|
||||
fmt.duration(data.this_month.worked),
|
||||
days_line(data.this_month),
|
||||
ctx,
|
||||
);
|
||||
let (balance_text, balance_note) = if settings.daily_target > SignedDuration::ZERO {
|
||||
let sign = if data.balance.is_negative() { "" } else { "+" };
|
||||
(
|
||||
format!("{sign}{}", fmt.duration(data.balance)),
|
||||
format!("vs {} per day worked", fmt.duration(settings.daily_target)),
|
||||
)
|
||||
} else {
|
||||
("-".to_string(), "set a daily target to see it".to_string())
|
||||
};
|
||||
let balance_title = format!("Balance · {}", self.period.label());
|
||||
card(
|
||||
frame,
|
||||
balance,
|
||||
&balance_title,
|
||||
balance_text,
|
||||
Line::styled(balance_note, ctx.theme.muted()),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Hours per week.
|
||||
let bars: Vec<Bar> = data
|
||||
.weeks
|
||||
.iter()
|
||||
.map(|(first, worked)| {
|
||||
let reached =
|
||||
settings.weekly_target > SignedDuration::ZERO && *worked >= settings.weekly_target;
|
||||
let color = if reached { ctx.theme.accent } else { ctx.theme.working };
|
||||
Bar::with_label(first.strftime("%b %-d").to_string(), worked.as_mins().max(0) as u64)
|
||||
.text_value(format!("{}h", worked.as_hours()))
|
||||
.style(Style::new().fg(color))
|
||||
})
|
||||
.collect();
|
||||
let bar_width =
|
||||
(weekly.width.saturating_sub(2) / data.weeks.len().max(1) as u16).saturating_sub(1).clamp(3, 8);
|
||||
let title = match settings.weekly_target {
|
||||
target if target > SignedDuration::ZERO => {
|
||||
format!(" Hours per week · target {} ", fmt.duration(target))
|
||||
}
|
||||
_ => " Hours per week ".to_string(),
|
||||
};
|
||||
frame.render_widget(
|
||||
BarChart::new(bars)
|
||||
.block(section(&title, ctx))
|
||||
.bar_width(bar_width)
|
||||
.bar_gap(1)
|
||||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||||
weekly,
|
||||
);
|
||||
|
||||
// When days start and end.
|
||||
if data.spans.is_empty() {
|
||||
frame.render_widget(empty(" When your days start and end ", self.period, ctx), spans);
|
||||
return;
|
||||
}
|
||||
let x_of = |date: Date| date.duration_since(range.first()).as_hours() as f64 / 24.0;
|
||||
let starts: Vec<(f64, f64)> =
|
||||
data.spans.iter().map(|span| (x_of(span.date), span.start_minute as f64 / 60.0)).collect();
|
||||
let ends: Vec<(f64, f64)> =
|
||||
data.spans.iter().map(|span| (x_of(span.date), span.end_minute as f64 / 60.0)).collect();
|
||||
let low = data.spans.iter().map(|span| span.start_minute).min().unwrap_or(0).div_euclid(60);
|
||||
let high = (data.spans.iter().map(|span| span.end_minute).max().unwrap_or(0) + 59)
|
||||
.div_euclid(60)
|
||||
.max(low + 1);
|
||||
let step = ((high - low) / 4).max(1);
|
||||
let y_labels: Vec<String> =
|
||||
(low..=high).step_by(step as usize).map(|hour| hour_label(hour, ctx)).collect();
|
||||
let x_labels = vec![fmt.date_short(range.first()), fmt.date_short(range.last())];
|
||||
let datasets = vec![
|
||||
Dataset::default()
|
||||
.name("start")
|
||||
.marker(Marker::Dot)
|
||||
.graph_type(GraphType::Scatter)
|
||||
.style(Style::new().fg(ctx.theme.working))
|
||||
.data(&starts),
|
||||
Dataset::default()
|
||||
.name("end")
|
||||
.marker(Marker::Dot)
|
||||
.graph_type(GraphType::Scatter)
|
||||
.style(Style::new().fg(ctx.theme.accent))
|
||||
.data(&ends),
|
||||
];
|
||||
let chart = Chart::new(datasets)
|
||||
.block(section(" When your days start and end ", ctx))
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.bounds([0.0, f64::from(range.day_count() - 1).max(1.0)])
|
||||
.labels(x_labels)
|
||||
.style(ctx.theme.muted()),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default().bounds([low as f64, high as f64]).labels(y_labels).style(ctx.theme.muted()),
|
||||
);
|
||||
frame.render_widget(chart, spans);
|
||||
}
|
||||
|
||||
fn render_calendar(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (settings, today) = (ctx.settings, ctx.now.date());
|
||||
let block = section(" Every day, like a wall calendar ", ctx);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let weeks = usize::from(inner.width.saturating_sub(6) / 2).clamp(1, 53);
|
||||
let heat = calendar(ctx.days, today, settings.week_start, weeks);
|
||||
let full = if settings.daily_target > SignedDuration::ZERO {
|
||||
settings.daily_target
|
||||
} else {
|
||||
SignedDuration::from_hours(8)
|
||||
};
|
||||
let cells: Vec<Vec<Option<f64>>> = (0..7)
|
||||
.map(|row| {
|
||||
heat.weeks
|
||||
.iter()
|
||||
.map(|week| week[row].map(|(_, worked)| worked.as_secs_f64() / full.as_secs_f64()))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let row_labels: Vec<String> =
|
||||
(0..7).map(|offset| weekday_name(settings.week_start.wrapping_add(offset))).collect();
|
||||
// A month's name above the week where it begins.
|
||||
let column_labels: Vec<(usize, String)> = heat
|
||||
.weeks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(column, week)| {
|
||||
let first = week.iter().flatten().find(|(date, _)| date.day() == 1 || column == 0)?;
|
||||
Some((column, first.0.strftime("%b").to_string()))
|
||||
})
|
||||
.collect();
|
||||
let today_row = today.weekday().since(settings.week_start) as usize;
|
||||
let heatmap = Heatmap {
|
||||
cells: &cells,
|
||||
row_labels: &row_labels,
|
||||
column_labels: &column_labels,
|
||||
cell: "■ ",
|
||||
highlight: Some((today_row, heat.weeks.len().saturating_sub(1))),
|
||||
theme: ctx.theme,
|
||||
};
|
||||
let (_, height) = heatmap.size();
|
||||
let [grid, _, legend] =
|
||||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Length(2)])
|
||||
.areas(inner);
|
||||
frame.render_widget(heatmap, grid);
|
||||
|
||||
let shown: Vec<&(Date, SignedDuration)> = heat.weeks.iter().flatten().flatten().collect();
|
||||
let worked_days = shown.iter().filter(|(_, worked)| *worked > SignedDuration::ZERO).count();
|
||||
let mut scale = vec![Span::styled(" less ", ctx.theme.muted())];
|
||||
for level in ctx.theme.heat {
|
||||
scale.push(Span::styled("■ ", Style::new().fg(level)));
|
||||
}
|
||||
scale.push(Span::styled(
|
||||
format!("more · a full square is {}", ctx.fmt.duration(full)),
|
||||
ctx.theme.muted(),
|
||||
));
|
||||
let summary = format!(" Worked on {worked_days} of the last {} days.", shown.len());
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![Line::from(scale), Line::styled(summary, ctx.theme.muted())]),
|
||||
legend,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let today = ctx.now.date();
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let data = rhythm(ctx.days, range, ctx.settings.week_start, ctx.clock.zone());
|
||||
let block = section(" When you work: weekday × hour of the day ", ctx);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
if data.busiest_cell() == 0 {
|
||||
frame.render_widget(empty("", self.period, ctx), inner);
|
||||
return;
|
||||
}
|
||||
|
||||
let busiest = data.busiest_cell() as f64;
|
||||
let cells: Vec<Vec<Option<f64>>> = data
|
||||
.seconds
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|seconds| Some(*seconds as f64 / busiest)).collect())
|
||||
.collect();
|
||||
let row_labels: Vec<String> = data.weekdays.iter().map(|weekday| weekday_name(*weekday)).collect();
|
||||
let cell = if inner.width >= 4 + 24 * 3 { "██ " } else { "██" };
|
||||
let column_labels: Vec<(usize, String)> =
|
||||
(0..24).step_by(3).map(|hour| (hour, ctx.fmt.hour_mark(hour as i8))).collect();
|
||||
let heatmap = Heatmap {
|
||||
cells: &cells,
|
||||
row_labels: &row_labels,
|
||||
column_labels: &column_labels,
|
||||
cell,
|
||||
highlight: None,
|
||||
theme: ctx.theme,
|
||||
};
|
||||
let (_, height) = heatmap.size();
|
||||
let [grid, _, notes] =
|
||||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Fill(1)])
|
||||
.areas(inner);
|
||||
frame.render_widget(heatmap, grid);
|
||||
|
||||
let peaks: Vec<String> = data
|
||||
.peak_hours(3)
|
||||
.into_iter()
|
||||
.map(|hour| format!("{}-{}", ctx.fmt.hour_mark(hour), ctx.fmt.hour_mark((hour + 1) % 24)))
|
||||
.collect();
|
||||
let total: SignedDuration = days_in(ctx.days, range).map(WorkDay::worked).sum();
|
||||
let per_week = SignedDuration::from_secs((total.as_secs() as f64 / data.weeks.max(1.0)) as i64);
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Busiest hours: ", ctx.theme.muted()),
|
||||
Span::styled(peaks.join(", "), ctx.theme.title()),
|
||||
]),
|
||||
Line::styled(
|
||||
format!(
|
||||
" Over {}: {} worked, about {} a week.",
|
||||
self.period.label(),
|
||||
ctx.fmt.duration(total),
|
||||
ctx.fmt.duration(per_week)
|
||||
),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(lines), notes);
|
||||
}
|
||||
|
||||
fn render_focus(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let data = focus(ctx.days, range, ctx.clock.zone(), settings.long_session);
|
||||
if data.sessions == 0 {
|
||||
frame.render_widget(empty(" Focus ", self.period, ctx), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let wide = area.width >= 100;
|
||||
let [chart_area, stats_area] = if wide {
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(area)
|
||||
} else {
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(10)]).areas(area)
|
||||
};
|
||||
|
||||
let bars: Vec<Bar> = SESSION_BUCKETS
|
||||
.iter()
|
||||
.zip(data.session_counts)
|
||||
.map(|((label, _), count)| {
|
||||
Bar::with_label(*label, count as u64)
|
||||
.text_value(count.to_string())
|
||||
.style(Style::new().fg(ctx.theme.working))
|
||||
})
|
||||
.collect();
|
||||
// Horizontal bars, one row each, so every label fits; a blank row
|
||||
// between bars when there's room.
|
||||
let rows = chart_area.height.saturating_sub(2);
|
||||
let gap = u16::from(rows >= 2 * SESSION_BUCKETS.len() as u16 - 1);
|
||||
frame.render_widget(
|
||||
BarChart::new(bars)
|
||||
.direction(Direction::Horizontal)
|
||||
.block(section(" How long your sessions last ", ctx))
|
||||
.bar_width(1)
|
||||
.bar_gap(gap)
|
||||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||||
chart_area,
|
||||
);
|
||||
|
||||
let row = |label: &str, value: String| {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {label:<17}"), ctx.theme.muted()),
|
||||
Span::styled(value, Style::new()),
|
||||
])
|
||||
};
|
||||
let typical_bucket = data
|
||||
.session_counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, count)| **count)
|
||||
.map(|(index, _)| SESSION_BUCKETS[index].0)
|
||||
.unwrap_or("");
|
||||
let mut lines = vec![
|
||||
row("Sessions", format!("{} · {:.1} a day", data.sessions, data.sessions_per_day())),
|
||||
row(
|
||||
"Typical session",
|
||||
data.median_session
|
||||
.map(|median| format!("{} (half are shorter)", fmt.duration(median)))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
row(
|
||||
"Longest session",
|
||||
data.longest_session
|
||||
.map(|(date, length)| format!("{} on {}", fmt.duration(length), fmt.date_short(date)))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
row(
|
||||
"Long sessions",
|
||||
format!("{} of {} or more", data.long_sessions, fmt.duration(settings.long_session)),
|
||||
),
|
||||
row(
|
||||
"Breaks",
|
||||
match data.median_break {
|
||||
Some(median) => format!("{} · typically {}", data.breaks, fmt.duration(median)),
|
||||
None => "none between sessions".to_string(),
|
||||
},
|
||||
),
|
||||
row(
|
||||
"Past midnight",
|
||||
format!("{} work day{}", data.past_midnight, if data.past_midnight == 1 { "" } else { "s" }),
|
||||
),
|
||||
Line::default(),
|
||||
];
|
||||
lines.push(Line::styled(
|
||||
format!(" Your sessions most often last {typical_bucket}."),
|
||||
ctx.theme.title(),
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(section(&format!(" {} ", self.period.label()), ctx)),
|
||||
stats_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Insights {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::INSIGHTS]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(false))
|
||||
.title(Line::styled(" Insights ", ctx.theme.title()));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let [tabs, _, body] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1)])
|
||||
.areas(inner);
|
||||
self.render_tabs(frame, tabs, ctx);
|
||||
match self.view {
|
||||
View::Overview => self.render_overview(frame, body, ctx),
|
||||
View::Calendar => self.render_calendar(frame, body, ctx),
|
||||
View::Rhythm => self.render_rhythm(frame, body, ctx),
|
||||
View::Focus => self.render_focus(frame, body, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, _count: Option<usize>, _ctx: &Context) -> Option<Action> {
|
||||
let index = View::ALL.iter().position(|view| *view == self.view).unwrap_or(0);
|
||||
match command {
|
||||
Command::Right => self.view = View::ALL[(index + 1) % View::ALL.len()],
|
||||
Command::Left => self.view = View::ALL[(index + View::ALL.len() - 1) % View::ALL.len()],
|
||||
Command::LongerPeriod => self.period = self.period.longer(),
|
||||
Command::ShorterPeriod => self.period = self.period.shorter(),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
|
||||
if event.kind == MouseEventKind::Down(MouseButton::Left) {
|
||||
let position = Position::new(event.column, event.row);
|
||||
if let Some((view, _)) = self.view_tabs.iter().find(|(_, rect)| rect.contains(position)) {
|
||||
self.view = *view;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A small box with a title, a big value and one line below it.
|
||||
fn card(frame: &mut Frame, area: Rect, title: &str, value: String, detail: Line<'static>, ctx: &Context) {
|
||||
let block = section(&format!(" {title} "), ctx);
|
||||
let value = Line::styled(format!(" {value}"), Style::new().add_modifier(Modifier::BOLD));
|
||||
let detail = Line::from([vec![Span::raw(" ")], detail.spans].concat());
|
||||
frame.render_widget(Paragraph::new(vec![value, detail]).block(block), area);
|
||||
}
|
||||
|
||||
/// The frame of a section: a rounded, muted border with a title.
|
||||
fn section<'a>(title: &str, ctx: &Context) -> Block<'a> {
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.muted())
|
||||
.title(Line::styled(title.to_string(), Style::new().add_modifier(Modifier::BOLD)))
|
||||
}
|
||||
|
||||
/// Shown when there's nothing to analyze yet.
|
||||
fn empty<'a>(title: &str, period: Period, ctx: &Context) -> Paragraph<'a> {
|
||||
let text =
|
||||
format!(" Nothing recorded over {} yet: this fills up as you track your work.", period.label());
|
||||
let paragraph = Paragraph::new(Span::styled(text, ctx.theme.muted()));
|
||||
if title.is_empty() { paragraph } else { paragraph.block(section(title, ctx)) }
|
||||
}
|
||||
|
||||
/// An hour on the chart's axis: hours past 24 are the next morning ("02+1").
|
||||
fn hour_label(hour: i64, ctx: &Context) -> String {
|
||||
let mark = ctx.fmt.hour_mark(hour.rem_euclid(24) as i8);
|
||||
match hour.div_euclid(24) {
|
||||
0 => mark,
|
||||
days => format!("{mark}+{days}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn weekday_name(weekday: Weekday) -> String {
|
||||
// Any date with that weekday gives its short name.
|
||||
let monday = jiff::civil::date(2024, 1, 1);
|
||||
monday.saturating_add((weekday.to_monday_zero_offset() as i64).days()).strftime("%a").to_string()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The four screens, one per tab.
|
||||
|
||||
pub mod days;
|
||||
pub mod insights;
|
||||
pub mod settings;
|
||||
pub mod tracker;
|
||||
|
||||
use jiff::civil::Date;
|
||||
|
||||
use super::Context;
|
||||
use super::keymap::CopyTarget;
|
||||
use crate::action::Action;
|
||||
use crate::export::timesheet::CopyPreset;
|
||||
|
||||
/// Copying from a screen: the day in focus, or its whole week. Without a
|
||||
/// preset, the one chosen in the settings is used.
|
||||
pub fn copy_action(target: CopyTarget, preset: Option<CopyPreset>, day: Date, ctx: &Context) -> Action {
|
||||
let days = match target {
|
||||
CopyTarget::Day => vec![day],
|
||||
CopyTarget::Week => ctx.week_days(day),
|
||||
};
|
||||
Action::Copy { days, preset: preset.unwrap_or(ctx.settings.copy_preset) }
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! The Settings screen, built from the settings table (`crate::settings::FIELDS`).
|
||||
//! Choices and on/off settings change with h/l; the others are typed (i).
|
||||
//! Expert settings are folded away until you open them with `za`.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::settings::{FIELDS, Field, Kind, SECTIONS};
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::widgets::text_input::{InputEvent, TextInput};
|
||||
use crate::ui::{Component, Context};
|
||||
|
||||
/// A row of the list: a section heading, or a setting (by its index in FIELDS).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Section(&'static str),
|
||||
Setting(usize),
|
||||
}
|
||||
|
||||
pub struct SettingsScreen {
|
||||
/// Index in FIELDS of the selected setting.
|
||||
selected: usize,
|
||||
show_expert: bool,
|
||||
/// The value being typed, while editing (vim insert mode).
|
||||
editing: Option<TextInput>,
|
||||
error: Option<String>,
|
||||
list_state: ListState,
|
||||
entries: Vec<Entry>,
|
||||
list_area: Rect,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub fn new() -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
selected: 0,
|
||||
show_expert: false,
|
||||
editing: None,
|
||||
error: None,
|
||||
list_state: ListState::default(),
|
||||
entries: Vec::new(),
|
||||
list_area: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings shown, as indexes in FIELDS, in section order.
|
||||
fn visible(&self) -> Vec<usize> {
|
||||
SECTIONS
|
||||
.iter()
|
||||
.flat_map(|(section, _)| {
|
||||
FIELDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(move |(_, field)| {
|
||||
field.section == *section && (self.show_expert || !field.expert)
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entries(&self) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
for index in self.visible() {
|
||||
let section = FIELDS[index].section;
|
||||
let description =
|
||||
SECTIONS.iter().find(|(name, _)| *name == section).map_or(section, |(_, text)| *text);
|
||||
if !entries.contains(&Entry::Section(description)) {
|
||||
entries.push(Entry::Section(description));
|
||||
}
|
||||
entries.push(Entry::Setting(index));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn field(&self) -> &'static Field {
|
||||
&FIELDS[self.selected]
|
||||
}
|
||||
|
||||
/// Moves the selection by `steps` settings (negative is up).
|
||||
fn move_by(&mut self, steps: isize) {
|
||||
let visible = self.visible();
|
||||
let position = visible.iter().position(|index| *index == self.selected).unwrap_or(0);
|
||||
let target = position.saturating_add_signed(steps).min(visible.len().saturating_sub(1));
|
||||
self.selected = visible[target];
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// The next (or previous) value of a choice or on/off setting.
|
||||
fn cycle(&self, forward: bool, ctx: &Context) -> Option<Action> {
|
||||
let field = self.field();
|
||||
let current = (field.get)(ctx.settings);
|
||||
let value = match &field.kind {
|
||||
Kind::Toggle => (current != "true").to_string(),
|
||||
Kind::Choice(names) => {
|
||||
let names = names();
|
||||
let index = names.iter().position(|name| *name == current).unwrap_or(0);
|
||||
let next =
|
||||
if forward { (index + 1) % names.len() } else { (index + names.len() - 1) % names.len() };
|
||||
names[next].to_string()
|
||||
}
|
||||
_ => return Some(Action::Notify("Press i to type a new value".into())),
|
||||
};
|
||||
Some(Action::SetSetting { key: field.key, value })
|
||||
}
|
||||
|
||||
/// Starts typing a new value (`keep` keeps the current one to edit).
|
||||
fn start_editing(&mut self, keep: bool, ctx: &Context) -> Option<Action> {
|
||||
match self.field().kind {
|
||||
Kind::Toggle | Kind::Choice(_) => self.cycle(true, ctx),
|
||||
_ => {
|
||||
let current = if keep { (self.field().get)(ctx.settings) } else { String::new() };
|
||||
self.editing = Some(TextInput::new(current));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_list(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
self.entries = self.entries();
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Entry::Section(description) => {
|
||||
ListItem::new(Line::styled(format!(" {description}"), ctx.theme.title()))
|
||||
}
|
||||
Entry::Setting(index) => {
|
||||
let field = &FIELDS[*index];
|
||||
let label = format!(" {:<28}", field.label);
|
||||
let value = match &self.editing {
|
||||
Some(input) if *index == self.selected => input.line(true, Style::new()),
|
||||
_ => Line::styled(display_value(field, ctx), ctx.theme.accent()),
|
||||
};
|
||||
let mut spans = vec![Span::raw(label)];
|
||||
spans.extend(value.spans);
|
||||
if field.expert {
|
||||
spans.push(Span::styled(" expert", ctx.theme.muted()));
|
||||
}
|
||||
ListItem::new(Line::from(spans))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let selected_row = self.entries.iter().position(|entry| *entry == Entry::Setting(self.selected));
|
||||
self.list_state.select(selected_row);
|
||||
|
||||
let fold = if self.show_expert { "za hide expert settings" } else { "za show expert settings" };
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(true))
|
||||
.title(Line::styled(" Settings ", ctx.theme.title()))
|
||||
.title_bottom(
|
||||
Line::styled(
|
||||
format!(" h/l change · i type · x default · {fold} "),
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
.right_aligned(),
|
||||
),
|
||||
)
|
||||
.highlight_style(if self.editing.is_some() {
|
||||
Style::new().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
ctx.theme.selected(true)
|
||||
});
|
||||
self.list_area = area;
|
||||
frame.render_stateful_widget(list, area, &mut self.list_state);
|
||||
}
|
||||
|
||||
fn render_help(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let field = self.field();
|
||||
let mut lines = vec![Line::from(field.help)];
|
||||
if let Some(error) = &self.error {
|
||||
lines.push(Line::styled(error.clone(), Style::new().fg(ctx.theme.error)));
|
||||
} else if self.editing.is_some() {
|
||||
lines.push(Line::styled("Enter to save · Esc to cancel", ctx.theme.muted()));
|
||||
}
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.muted())
|
||||
.title(Line::styled(format!(" {} ", field.label), Style::new().add_modifier(Modifier::BOLD)))
|
||||
.title_bottom(
|
||||
Line::styled(format!(" [{}] {} ", field.section, field.key), ctx.theme.muted())
|
||||
.right_aligned(),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a value is shown: empty paths say what empty means.
|
||||
/// How a value is shown: on/off instead of true/false, and empty paths say
|
||||
/// what empty means.
|
||||
fn display_value(field: &Field, ctx: &Context) -> String {
|
||||
let value = (field.get)(ctx.settings);
|
||||
match (field.key, value.as_str()) {
|
||||
(_, "true") if matches!(field.kind, Kind::Toggle) => "on".to_string(),
|
||||
(_, "false") if matches!(field.kind, Kind::Toggle) => "off".to_string(),
|
||||
("folder", "") => format!("(Documents) {}", ctx.export_folder.display()),
|
||||
("data_file", "") => "(default place)".to_string(),
|
||||
("clipboard_command", "") => "(none)".to_string(),
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for SettingsScreen {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::SETTINGS, keymap::LIST]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let [list, help] = Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(area);
|
||||
self.render_list(frame, list, ctx);
|
||||
self.render_help(frame, help, ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let steps = count.unwrap_or(1) as isize;
|
||||
let half_page = (self.list_area.height / 2).max(1) as isize;
|
||||
match command {
|
||||
Command::Down => self.move_by(steps),
|
||||
Command::Up => self.move_by(-steps),
|
||||
Command::HalfPageDown => self.move_by(half_page),
|
||||
Command::HalfPageUp => self.move_by(-half_page),
|
||||
Command::Top => self.move_by(isize::MIN / 2),
|
||||
Command::Bottom => self.move_by(isize::MAX / 2),
|
||||
Command::Left => return self.cycle(false, ctx),
|
||||
Command::Right => return self.cycle(true, ctx),
|
||||
Command::Edit | Command::Confirm => return self.start_editing(true, ctx),
|
||||
Command::Retype => return self.start_editing(false, ctx),
|
||||
Command::ResetSetting => return Some(Action::ResetSetting { key: self.field().key }),
|
||||
Command::ToggleExpert => {
|
||||
self.show_expert = !self.show_expert;
|
||||
// If the selected setting was just hidden, select a visible one.
|
||||
self.move_by(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
|
||||
let position = Position::new(event.column, event.row);
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollDown => self.move_by(1),
|
||||
MouseEventKind::ScrollUp => self.move_by(-1),
|
||||
MouseEventKind::Down(MouseButton::Left) if self.list_area.contains(position) => {
|
||||
let row =
|
||||
usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
|
||||
if let Some(Entry::Setting(index)) = self.entries.get(row) {
|
||||
if *index == self.selected {
|
||||
return self.cycle(true, ctx);
|
||||
}
|
||||
self.selected = *index;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn inserting(&self) -> bool {
|
||||
self.editing.is_some()
|
||||
}
|
||||
|
||||
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
|
||||
let input = self.editing.as_mut()?;
|
||||
match input.insert_key(key) {
|
||||
InputEvent::Submit => {
|
||||
let value = input.text().to_string();
|
||||
// Check it now, to keep the field open while it's wrong.
|
||||
let mut trial = ctx.settings.clone();
|
||||
match (self.field().set)(&mut trial, &value) {
|
||||
Ok(()) => {
|
||||
self.editing = None;
|
||||
self.error = None;
|
||||
Some(Action::SetSetting { key: self.field().key, value })
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
InputEvent::Exit => {
|
||||
self.editing = None;
|
||||
self.error = None;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! The Tracker screen: what's happening right now, big and clear.
|
||||
//!
|
||||
//! ```text
|
||||
//! ● Working since 21:02 · work day Thu Sep 24
|
||||
//!
|
||||
//! █ ███ ███ ███ ███ ███
|
||||
//! ... (the running timer)
|
||||
//!
|
||||
//! Focus 18m / 25m ●●○○
|
||||
//!
|
||||
//! ╭───────────╮ ╭───────────╮ ╭─────────────╮
|
||||
//! │ ▶ Start s │ │ ‖ Pause p │ │ ■ End day e │
|
||||
//! ╰───────────╯ ╰───────────╯ ╰─────────────╯
|
||||
//!
|
||||
//! Today 5h 40m ██████████████░░░░░░ 71% of 8h
|
||||
//! In 22:00 · 1 break (30m) · 2 sessions
|
||||
//! 22 00 02 04
|
||||
//! ██████████████░░██████▌
|
||||
//!
|
||||
//! This week 32h 10m ████████████████░░░░ 80% of 40h
|
||||
//! ```
|
||||
|
||||
use jiff::SignedDuration;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Paragraph};
|
||||
|
||||
use super::copy_action;
|
||||
use crate::action::Action;
|
||||
use crate::domain::{ShiftState, WorkDay};
|
||||
use crate::reminders::Phase;
|
||||
use crate::time::DateRange;
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::popups::copy_menu::CopyMenu;
|
||||
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
|
||||
use crate::ui::widgets::big_digits::{self, BigDigits};
|
||||
use crate::ui::widgets::button::{self, Button, Look};
|
||||
use crate::ui::widgets::timeline::{self, Timeline, window_for};
|
||||
use crate::ui::widgets::{centered, progress_bar};
|
||||
use crate::ui::{Component, Context, popup};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Tracker {
|
||||
/// Where each clickable button was drawn, for the mouse.
|
||||
buttons: Vec<(Rect, ButtonAction)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ButtonAction {
|
||||
Start,
|
||||
Pause,
|
||||
EndDay,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
pub fn new() -> Tracker {
|
||||
Tracker::default()
|
||||
}
|
||||
|
||||
/// `i`/Enter: fix the session in progress, or else today's last segment.
|
||||
fn edit(&self, ctx: &Context) -> Action {
|
||||
if let ShiftState::Working { day, since } = ctx.journal.state() {
|
||||
return popup(SegmentEditor::new(Target::Running { day, since }, ctx));
|
||||
}
|
||||
let day = ctx.current_day();
|
||||
let last = ctx.journal.day(day).and_then(|work_day| {
|
||||
let index = work_day.segments().len().checked_sub(1)?;
|
||||
Some((index, work_day.segments()[index]))
|
||||
});
|
||||
match last {
|
||||
Some((index, original)) => {
|
||||
popup(SegmentEditor::new(Target::Existing { day, index, original }, ctx))
|
||||
}
|
||||
None => Action::Notify("Nothing recorded today yet (o adds work you forgot to track)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let state = ctx.journal.state();
|
||||
let (symbol, text) = match state {
|
||||
ShiftState::Working { day, since } => {
|
||||
let paid_break = matches!(ctx.pomodoro.phase, Phase::Break { paid: true, .. });
|
||||
let status = if paid_break { " · on a paid break" } else { "" };
|
||||
(
|
||||
"●",
|
||||
format!(
|
||||
"Working since {}{status} · work day {}",
|
||||
ctx.time(since),
|
||||
ctx.fmt.date_short(day)
|
||||
),
|
||||
)
|
||||
}
|
||||
ShiftState::OnBreak { day } => {
|
||||
("◐", format!("On a break · work day {}", ctx.fmt.date_short(day)))
|
||||
}
|
||||
ShiftState::Idle => ("○", "Not working · press s to start".to_string()),
|
||||
};
|
||||
let color = ctx.theme.state(state);
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{symbol} "), Style::new().fg(color)),
|
||||
Span::styled(text, Style::new().add_modifier(Modifier::BOLD)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line).centered(), area);
|
||||
}
|
||||
|
||||
/// The big number: the running session, the break, or the time of day.
|
||||
fn render_timer(&self, frame: &mut Frame, area: Rect, big: bool, ctx: &Context) {
|
||||
let now = ctx.now.timestamp();
|
||||
let show_seconds = ctx.settings.show_seconds;
|
||||
let state = ctx.journal.state();
|
||||
let (text, color) = match state {
|
||||
ShiftState::Working { .. } => {
|
||||
(ctx.fmt.stopwatch(ctx.journal.running_for(now), show_seconds), ctx.theme.working)
|
||||
}
|
||||
ShiftState::OnBreak { .. } => {
|
||||
let on_break = ctx.journal.on_break_for(now).unwrap_or_default();
|
||||
(ctx.fmt.stopwatch(on_break, show_seconds), ctx.theme.on_break)
|
||||
}
|
||||
ShiftState::Idle => {
|
||||
let clock = if show_seconds {
|
||||
ctx.fmt.time_with_seconds(ctx.now.time())
|
||||
} else {
|
||||
ctx.fmt.time(ctx.now.time())
|
||||
};
|
||||
(clock, ctx.theme.idle)
|
||||
}
|
||||
};
|
||||
let style = Style::new().fg(color).add_modifier(Modifier::BOLD);
|
||||
// Big digits only know digits and colons; "10:05 PM" keeps its "PM"
|
||||
// as small text below.
|
||||
let (digits, suffix) = match text.split_once(' ') {
|
||||
Some((digits, suffix)) => (digits.to_string(), suffix.to_string()),
|
||||
None => (text, String::new()),
|
||||
};
|
||||
if big {
|
||||
let big_digits = BigDigits::new(&digits, style);
|
||||
let width = big_digits.width();
|
||||
frame.render_widget(big_digits, area);
|
||||
if !suffix.is_empty() {
|
||||
// "PM" in small letters after the last digit, on the bottom row.
|
||||
let x = area.x + area.width.saturating_sub(width) / 2 + width + 1;
|
||||
let label =
|
||||
Rect { x, y: area.bottom().saturating_sub(1), width: 2, height: 1 }.intersection(area);
|
||||
frame.render_widget(Paragraph::new(Span::styled(suffix, style)), label);
|
||||
}
|
||||
} else {
|
||||
let text = format!("{digits} {suffix}");
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(text.trim_end().to_string(), style)).centered(),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pomodoro line: how far into focus or break you are, or a nudge.
|
||||
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let pomodoro = &ctx.pomodoro;
|
||||
let fmt = &ctx.fmt;
|
||||
let dots: String = (0..pomodoro.long_break_every)
|
||||
.map(|index| if index < pomodoro.cycle_position() { '●' } else { '○' })
|
||||
.collect();
|
||||
let snoozed = ctx.snoozed_until.filter(|until| *until > ctx.now.timestamp());
|
||||
let settings = ctx.settings;
|
||||
|
||||
let line = match pomodoro.phase {
|
||||
Phase::Off => Line::default(),
|
||||
Phase::Focus { focused, goal, .. } if focused < goal => Line::from(vec![
|
||||
Span::styled("Focus ", ctx.theme.muted()),
|
||||
Span::raw(format!("{} / {} ", fmt.duration(focused), fmt.duration(goal))),
|
||||
Span::styled(dots, ctx.theme.accent()),
|
||||
]),
|
||||
Phase::Focus { focused, .. } => match snoozed {
|
||||
Some(until) => Line::styled(
|
||||
format!("Break suggestion snoozed until {}", ctx.time(until)),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
None => {
|
||||
let every = pomodoro.long_break_every.max(1);
|
||||
let long = pomodoro.completed.is_multiple_of(every);
|
||||
let length = if long { settings.long_break } else { settings.short_break };
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"Break time: {} of focus. A {} break would fit.",
|
||||
fmt.duration(focused),
|
||||
fmt.duration(length)
|
||||
),
|
||||
Style::new().fg(ctx.theme.warning).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
if settings.paid_breaks {
|
||||
" b break · zz snooze"
|
||||
} else {
|
||||
" p pause · zz snooze"
|
||||
},
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
])
|
||||
}
|
||||
},
|
||||
Phase::Break { taken, goal, long, paid, .. } if taken < goal => Line::from(vec![
|
||||
Span::styled(
|
||||
match (long, paid) {
|
||||
(true, true) => "Long paid break ",
|
||||
(true, false) => "Long break ",
|
||||
(false, true) => "Paid break ",
|
||||
(false, false) => "Break ",
|
||||
},
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
Span::raw(format!("{} / {} ", fmt.duration(taken), fmt.duration(goal))),
|
||||
Span::styled(dots, ctx.theme.accent()),
|
||||
Span::styled(if paid { " b back" } else { "" }, ctx.theme.muted()),
|
||||
]),
|
||||
Phase::Break { goal, paid, .. } => Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Break's over ({}). Back when you're ready.", fmt.duration(goal)),
|
||||
ctx.theme.title(),
|
||||
),
|
||||
Span::styled(if paid { " b back" } else { " s start" }, ctx.theme.muted()),
|
||||
]),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(line).centered(), area);
|
||||
}
|
||||
|
||||
fn render_buttons(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let state = ctx.journal.state();
|
||||
let working = state.is_working();
|
||||
let start_label =
|
||||
if matches!(state, ShiftState::OnBreak { .. }) { "▶ Resume" } else { "▶ Start" };
|
||||
let buttons = [
|
||||
(
|
||||
Button::new(
|
||||
start_label,
|
||||
"s",
|
||||
if working { Look::Disabled } else { Look::Primary },
|
||||
ctx.theme,
|
||||
),
|
||||
ButtonAction::Start,
|
||||
),
|
||||
(
|
||||
Button::new("‖ Pause", "p", if working { Look::Primary } else { Look::Disabled }, ctx.theme),
|
||||
ButtonAction::Pause,
|
||||
),
|
||||
(
|
||||
Button::new(
|
||||
"■ End day",
|
||||
"e",
|
||||
if state.open_day().is_some() { Look::Normal } else { Look::Disabled },
|
||||
ctx.theme,
|
||||
),
|
||||
ButtonAction::EndDay,
|
||||
),
|
||||
];
|
||||
let gap = 2;
|
||||
let total: u16 = buttons.iter().map(|(button, _)| button.width() + gap).sum::<u16>() - gap;
|
||||
let mut x = area.x + area.width.saturating_sub(total) / 2;
|
||||
|
||||
self.buttons.clear();
|
||||
for (button, action) in buttons {
|
||||
let rect =
|
||||
Rect { x, y: area.y, width: button.width(), height: button::HEIGHT }.intersection(area);
|
||||
x += button.width() + gap;
|
||||
if button.look() != Look::Disabled {
|
||||
self.buttons.push((rect, action));
|
||||
}
|
||||
frame.render_widget(button, rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_today(&self, frame: &mut Frame, areas: [Rect; 3], ctx: &Context) {
|
||||
let [progress, details, timeline_area] = areas;
|
||||
let fmt = &ctx.fmt;
|
||||
let day = ctx.day(ctx.current_day());
|
||||
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
|
||||
frame.render_widget(
|
||||
Paragraph::new(goal_line("Today", worked, ctx.settings.daily_target, progress.width, ctx)),
|
||||
progress,
|
||||
);
|
||||
|
||||
let details_text = match day.filter(|day| !day.is_empty()) {
|
||||
None => "Nothing recorded yet today.".to_string(),
|
||||
Some(day) => {
|
||||
let sessions = day.segments().len();
|
||||
let breaks = match day.breaks().count() {
|
||||
0 => "no breaks yet".to_string(),
|
||||
1 => format!("1 break ({})", fmt.duration(day.break_time())),
|
||||
count => format!("{count} breaks ({})", fmt.duration(day.break_time())),
|
||||
};
|
||||
format!(
|
||||
"In {} · {breaks} · {sessions} session{}",
|
||||
day.first_start().map(|start| ctx.time(start)).unwrap_or_default(),
|
||||
if sessions == 1 { "" } else { "s" },
|
||||
)
|
||||
}
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Span::styled(details_text, ctx.theme.muted())), details);
|
||||
|
||||
let pieces: Vec<_> =
|
||||
day.map(|day| day.segments().iter().map(|s| (s.start(), s.end())).collect()).unwrap_or_default();
|
||||
let state = ctx.journal.state();
|
||||
let now = (state.open_day() == Some(ctx.current_day())).then(|| ctx.now.timestamp());
|
||||
let running_since = match state {
|
||||
ShiftState::Working { since, .. } => Some(since),
|
||||
_ => None,
|
||||
};
|
||||
frame.render_widget(
|
||||
Timeline {
|
||||
pieces: &pieces,
|
||||
running_since,
|
||||
now,
|
||||
window: window_for(&pieces, now, ctx.clock.zone(), 8),
|
||||
zone: ctx.clock.zone(),
|
||||
fmt: &ctx.fmt,
|
||||
theme: ctx.theme,
|
||||
},
|
||||
timeline_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_week(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let week = DateRange::week_of(ctx.now.date(), ctx.settings.week_start);
|
||||
let worked: SignedDuration =
|
||||
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
|
||||
frame.render_widget(
|
||||
Paragraph::new(goal_line("This week", worked, ctx.settings.weekly_target, area.width, ctx)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Tracker {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::TRACKER]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let block = Block::bordered().border_type(BorderType::Rounded).border_style(ctx.theme.border(false));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let column = centered(inner, inner.width.min(76), inner.height);
|
||||
|
||||
let big = ctx.settings.big_timer && column.height >= 24;
|
||||
let space = u16::from(column.height >= 20);
|
||||
let [status, timer, rhythm, buttons, progress, details, timeline_area, week] = Layout::vertical([
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(if big { big_digits::HEIGHT + 2 * space } else { 1 + space }),
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(button::HEIGHT + space),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(timeline::HEIGHT + space),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(column);
|
||||
|
||||
let inset = |area: Rect| Rect { x: area.x + 2, width: area.width.saturating_sub(4), ..area };
|
||||
let first_row = |area: Rect, height: u16| Rect { height: height.min(area.height), ..area };
|
||||
|
||||
self.render_status(frame, first_row(status, 1), ctx);
|
||||
let timer_area = if big {
|
||||
Rect { y: timer.y + space, height: big_digits::HEIGHT, ..timer }
|
||||
} else {
|
||||
first_row(timer, 1)
|
||||
};
|
||||
self.render_timer(frame, timer_area, big, ctx);
|
||||
self.render_rhythm(frame, first_row(rhythm, 1), ctx);
|
||||
self.render_buttons(frame, first_row(buttons, button::HEIGHT), ctx);
|
||||
self.render_today(
|
||||
frame,
|
||||
[
|
||||
inset(progress),
|
||||
inset(first_row(details, 1)),
|
||||
inset(first_row(timeline_area, timeline::HEIGHT)),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
self.render_week(frame, inset(week), ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let day = ctx.current_day();
|
||||
match command {
|
||||
Command::Edit => Some(self.edit(ctx)),
|
||||
Command::AddAfter | Command::AddBefore => {
|
||||
Some(popup(SegmentEditor::new(Target::New { day, after: true }, ctx)))
|
||||
}
|
||||
Command::Copy(target, preset) => Some(copy_action(target, preset, day, ctx)),
|
||||
Command::CopyMenu => Some(popup(CopyMenu::new(day, ctx))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
|
||||
if event.kind != MouseEventKind::Down(MouseButton::Left) {
|
||||
return None;
|
||||
}
|
||||
let position = Position::new(event.column, event.row);
|
||||
let (_, action) = self.buttons.iter().find(|(rect, _)| rect.contains(position))?;
|
||||
Some(match action {
|
||||
ButtonAction::Start => Action::Start,
|
||||
ButtonAction::Pause => Action::Pause,
|
||||
ButtonAction::EndDay => Action::EndDay,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// "Today 5h 40m ██████░░░░ 71% of 8h", or just "Today 5h 40m" without a target.
|
||||
fn goal_line(
|
||||
label: &str,
|
||||
worked: SignedDuration,
|
||||
target: SignedDuration,
|
||||
width: u16,
|
||||
ctx: &Context,
|
||||
) -> Line<'static> {
|
||||
let mut spans = vec![
|
||||
Span::styled(format!("{label:<10}"), ctx.theme.muted()),
|
||||
Span::styled(format!("{:<9}", ctx.fmt.duration(worked)), Style::new().add_modifier(Modifier::BOLD)),
|
||||
];
|
||||
if target > SignedDuration::ZERO {
|
||||
let ratio = worked.as_secs_f64() / target.as_secs_f64();
|
||||
let tail = format!(" {:>3.0}% of {}", ratio * 100.0, ctx.fmt.duration(target));
|
||||
let bar_width = width.saturating_sub(20 + tail.chars().count() as u16).min(40);
|
||||
spans.extend(progress_bar(bar_width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()));
|
||||
spans.push(Span::styled(tail, ctx.theme.muted()));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
Reference in new issue
Block a user