Compare commits

...
10 Commits
Author SHA1 Message Date
skkeyeandClaude Opus 5.5 253d72221b Mark times on the day before a work day as -1
A time on the day after its work day shows "+1" (a night shift ending at
04:10 +1). A time on the day before, which a start backdated across
midnight can produce, showed "+-1". It now shows "-1", on the Days
screen and in Markdown reports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:25:36 -04:00
skkeyeandClaude Opus 5.5 ae9e1b1f08 Show session lengths in ten categories
The Focus view grouped sessions into six buckets, too coarse to see a
rhythm. There are now ten: under 5m, 5-15m, 15-25m, 25-45m, 45m-1h,
1h-1h30m, 1h30m-2h, 2-3h, 3-4h and 4h or more. 25 minutes is a classic
pomodoro, and the long end separates 3-4h stretches from 4h+ hyperfocus.

Ten labelled columns don't fit side by side, so the chart is now drawn
with horizontal bars: every label stays readable at any width.

The buckets are one constant (analysis::focus::SESSION_BUCKETS); a new
test pins down where sessions land, including an exact boundary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:25:14 -04:00
skkeyeandClaude Opus 5.5 49e14b820a Add paid pomodoro breaks
Some jobs pay for breaks, so a pomodoro break shouldn't take time off
the workday. By default (new setting `paid_breaks`, in [reminders]), b
on the Tracker, zb anywhere or :break starts a pomodoro break WITHOUT
pausing the shift: the break stays in your worked time and the data
file isn't touched. b (or s) brings you back to focus. With the setting
off, b pauses the shift like p. p and e always stop the clock.

For the rhythm, a paid break still counts as a break: reminders::PaidBreaks
keeps them in memory and pomodoro() cuts them out of the focus stretches,
the same way as a pause. Stopping the shift ends a paid break, and
closing the work day forgets them.

The Tracker shows "on a paid break" and the paid break's progress, and
the nudges name the right key (b or p/s).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:06:26 -04:00
skkeyeandClaude Opus 5.5 fe5e2d9a7f Accept date shortcuts in the Work day field
Adding work to a day you forgot to track meant typing its full date.
The Work day field now also takes 09-14 (this year), today, yesterday,
-3 (three days ago) and weekday names like mon or friday (the most
recent one). The editor shows the date it understood next to the field.

The parsing lives in time::format::parse_date, next to parse_time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:02:43 -04:00
skkeyeandClaude Opus 5.5 9257181a77 Fix added work landing on the wrong day
When another date was typed in the segment editor's Work day field, the
segment was filed under that date but its times were still placed around
the original day (usually today). A segment filed under Sep 14 could run
on Sep 24.

The editor now reads the Work day field first and places the typed times
on that day: next to its existing work, or around noon on an empty day.
Moving an existing segment to another work day moves its times with it,
keeping the same clock times.

The test helper also learns <Tab> and <BS>, and a regression test adds
work both from the Days list and by typing another date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:01:40 -04:00
skkeyeandClaude Opus 5.5 38f14fa453 Keep LF line endings on every OS
The same repository is used on Windows, Void and Fedora; .gitattributes
stops Git from converting files to CRLF on Windows checkouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:35 -04:00
skkeyeandClaude Opus 5.5 c21b720eea Add cargo linux to build a static Linux binary from any OS
Builds for x86_64-unknown-linux-musl with rust-lld. The result is fully
static, so the same file runs on Void (glibc or musl) and Fedora. The
linker is set inside the alias, not globally, so native builds on
Void-musl keep their usual linker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:23 -04:00
skkeyeandClaude Opus 5.5 235c4f0ad5 Add a README: building, everyday keys, files and code map
How to build on Windows, Void and Fedora, the keys to know, how work
days behave past midnight, where the files are, and how the code is
organized (with the two most common changes: keys and settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:23 -04:00
skkeyeandClaude Opus 5.5 89c5279f52 Add the app core and the real entry point
app.rs holds everything and runs the loop: keys become commands, the
screen turns them into actions, and App::apply is the only place data
changes. Journal edits happen on a copy, are undoable, saved atomically
and rolled back if saving fails. It also runs the reminders and picks
up outside edits of shifts.toml every second.

main.rs loads the settings, takes the lock, makes the daily backup and
starts the UI; --config, --paths, --help and --version.

The tests drive the whole app with fake key presses and draw every
screen and popup at several sizes in an in-memory terminal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:23 -04:00
skkeyeandClaude Opus 5.5 57ea9403b6 Add the Tracker, Days, Insights and Settings screens
- Tracker: big running timer, pomodoro line, clickable buttons, today's
  progress and timeline, this week.
- Days: work days grouped by week, the selected day's details, timeline
  and segments to edit, add, delete and copy.
- Insights: overview cards and charts, calendar heatmap, weekday x hour
  rhythm, session lengths and breaks.
- Settings: generated from the settings table; expert settings are
  folded away (za).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 02:59:22 -04:00
21 changed files with 3226 additions and 47 deletions

No files matched your search

+8
View File
@@ -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'",
]
+2
View File
@@ -0,0 +1,2 @@
# Keep LF line endings everywhere (Windows and Linux share this repo).
* text=auto eol=lf
+126
View File
@@ -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
```
+3
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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)),
}
}
+3
View File
@@ -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"),
+1
View File
@@ -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;
+2
View File
@@ -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)),
+38 -26
View File
@@ -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());
+406
View File
@@ -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
}
}
+531
View File
@@ -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()
}
+23
View File
@@ -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) }
}
+297
View File
@@ -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,
}
}
}
+432
View File
@@ -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)
}