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]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-25 00:06:26 -04:00
1 parent fe5e2d9a7f
commit 49e14b820a
8 files changed
+233 -21

No files matched your search

+5
View File
@@ -41,6 +41,7 @@ in the settings.
| `<Space>c` | copy in another format, with a preview | | `<Space>c` | copy in another format, with a preview |
| `gh` `gd` `gi` `gs` | Tracker, Days, Insights, Settings (also `gt`, `Tab`) | | `gh` `gd` `gi` `gs` | Tracker, Days, Insights, Settings (also `gt`, `Tab`) |
| `u` `Ctrl-r` | undo, redo: mistakes cost nothing | | `u` `Ctrl-r` | undo, redo: mistakes cost nothing |
| `b` (`zb`) | take a pomodoro break, and come back from it |
| `zz` | snooze the break suggestion | | `zz` | snooze the break suggestion |
| `:` | command line: `:pause 17:30`, `:export csv lastmonth`, `:set time_format=12h` | | `:` | command line: `:pause 17:30`, `:export csv lastmonth`, `:set time_format=12h` |
| `?` | every key, for the screen you're on | | `?` | every key, for the screen you're on |
@@ -60,6 +61,10 @@ settings, or with `<Space>w`.
- Times you type (`22:30`, `2230`, `22h30`, `10:30pm`, `10p`) are read in - 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 both 12h and 24h styles. An end time before the start time means the next
day. 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 - 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 day** field: `2026-09-14`, `09-14`, `yesterday`, `-3` (3 days ago) or a
weekday like `mon` all work. weekday like `mon` all work.
+3
View File
@@ -41,6 +41,9 @@ pub enum Action {
since: Timestamp, since: Timestamp,
stopped: Option<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. /// Silence the break suggestion for a while.
Snooze, Snooze,
+72 -7
View File
@@ -26,7 +26,7 @@ use crate::action::Action;
use crate::domain::{DomainError, Journal, ShiftState, StartOutcome, WorkDay}; use crate::domain::{DomainError, Journal, ShiftState, StartOutcome, WorkDay};
use crate::export::clipboard::Clipboard; use crate::export::clipboard::Clipboard;
use crate::export::{self, Report, timesheet}; use crate::export::{self, Report, timesheet};
use crate::reminders::{self, Phase}; use crate::reminders::{self, PaidBreaks, Phase};
use crate::settings::{FIELDS, Settings}; use crate::settings::{FIELDS, Settings};
use crate::storage::journal_file::{JournalStore, OutsideChange}; use crate::storage::journal_file::{JournalStore, OutsideChange};
use crate::storage::paths::Paths; use crate::storage::paths::Paths;
@@ -77,6 +77,8 @@ struct Model {
paths: Paths, paths: Paths,
export_folder: PathBuf, export_folder: PathBuf,
nudges: Nudges, nudges: Nudges,
/// Pomodoro breaks taken without pausing the shift.
paid_breaks: PaidBreaks,
} }
/// What the reminders already said, so each is said only once. /// What the reminders already said, so each is said only once.
@@ -139,6 +141,7 @@ impl App {
store, store,
paths, paths,
nudges: Nudges::default(), nudges: Nudges::default(),
paid_breaks: PaidBreaks::default(),
}; };
let mut app = App { let mut app = App {
model, model,
@@ -356,6 +359,7 @@ impl App {
Command::PauseAt => Some(popup(TimePrompt::new(Purpose::Pause))), Command::PauseAt => Some(popup(TimePrompt::new(Purpose::Pause))),
Command::EndDayAt => Some(popup(TimePrompt::new(Purpose::EndDay))), Command::EndDayAt => Some(popup(TimePrompt::new(Purpose::EndDay))),
Command::Snooze => Some(Action::Snooze), Command::Snooze => Some(Action::Snooze),
Command::TakeBreak => Some(Action::TakeBreak),
Command::Undo => Some(Action::Undo), Command::Undo => Some(Action::Undo),
Command::Redo => Some(Action::Redo), Command::Redo => Some(Action::Redo),
Command::Help => Some(Action::ShowHelp), Command::Help => Some(Action::ShowHelp),
@@ -397,6 +401,12 @@ impl App {
} }
Action::Notify(text) => self.say(Tone::Info, text), Action::Notify(text) => self.say(Tone::Info, text),
// During a paid break the shift is still running: Start means
// "back to focus".
Action::Start if self.model.on_paid_break() => {
self.model.paid_breaks.finish(self.model.clock.now().timestamp());
self.say(Tone::Good, "Back to focus. The break stayed in your worked time.");
}
Action::Start => { Action::Start => {
let now = self.model.clock.punch(); let now = self.model.clock.punch();
let new_day_after = self.model.settings.new_day_after_break; let new_day_after = self.model.settings.new_day_after_break;
@@ -468,6 +478,25 @@ impl App {
}; };
self.say(Tone::Good, text); self.say(Tone::Good, text);
} }
Action::TakeBreak => {
let state = self.model.journal.state();
// Without paid breaks, a pomodoro break is a plain pause.
if !self.model.settings.paid_breaks || !state.is_working() {
return match state {
ShiftState::Working { .. } => self.apply(Action::Pause),
ShiftState::OnBreak { .. } => self.apply(Action::Start),
ShiftState::Idle => Err("You're not working, so there's no break to take".into()),
};
}
let now = self.model.clock.now().timestamp();
if self.model.on_paid_break() {
self.model.paid_breaks.finish(now);
self.say(Tone::Good, "Back to focus. The break stayed in your worked time.");
} else {
self.model.paid_breaks.start(now);
self.say(Tone::Good, "Paid break: the shift keeps running. b when you're back.");
}
}
Action::Snooze => { Action::Snooze => {
let until = self.model.clock.now().timestamp() + self.model.settings.snooze; let until = self.model.clock.now().timestamp() + self.model.settings.snooze;
self.model.nudges.snoozed_until = Some(until); self.model.nudges.snoozed_until = Some(until);
@@ -607,7 +636,7 @@ impl App {
self.model.nudges.break_suggested = None; self.model.nudges.break_suggested = None;
} }
let pomodoro = reminders::pomodoro(&self.model.journal, now, &self.model.settings.pomodoro()); let pomodoro = self.model.pomodoro(now);
let nudge = match pomodoro.phase { let nudge = match pomodoro.phase {
Phase::Focus { started, focused, .. } Phase::Focus { started, focused, .. }
if pomodoro.break_due() if pomodoro.break_due()
@@ -615,13 +644,16 @@ impl App {
&& self.model.nudges.break_suggested != Some(started) => && self.model.nudges.break_suggested != Some(started) =>
{ {
self.model.nudges.break_suggested = Some(started); self.model.nudges.break_suggested = Some(started);
Some(format!("Break time: {} of focus. p pauses, zz snoozes.", fmt.duration(focused))) let take_it =
if self.model.settings.paid_breaks { "b takes a paid break" } else { "p pauses" };
Some(format!("Break time: {} of focus. {take_it}, zz snoozes.", fmt.duration(focused)))
} }
Phase::Break { started, goal, .. } Phase::Break { started, goal, paid, .. }
if pomodoro.break_over() && self.model.nudges.break_over != Some(started) => if pomodoro.break_over() && self.model.nudges.break_over != Some(started) =>
{ {
self.model.nudges.break_over = Some(started); self.model.nudges.break_over = Some(started);
Some(format!("Your {} break is over. s when you're ready.", fmt.duration(goal))) let back = if paid { "b" } else { "s" };
Some(format!("Your {} break is over. {back} when you're ready.", fmt.duration(goal)))
} }
_ => None, _ => None,
}; };
@@ -659,12 +691,22 @@ impl Model {
now, now,
fmt: self.settings.formatter(), fmt: self.settings.formatter(),
theme: &self.theme, theme: &self.theme,
pomodoro: reminders::pomodoro(&self.journal, now.timestamp(), &self.settings.pomodoro()), pomodoro: self.pomodoro(now.timestamp()),
snoozed_until: self.nudges.snoozed_until, snoozed_until: self.nudges.snoozed_until,
export_folder: &self.export_folder, export_folder: &self.export_folder,
} }
} }
/// Where you are in the pomodoro rhythm.
fn pomodoro(&self, now: Timestamp) -> reminders::Pomodoro {
reminders::pomodoro(&self.journal, &self.paid_breaks, now, &self.settings.pomodoro())
}
/// On a paid break: the shift runs, but you're taking a pomodoro break.
fn on_paid_break(&self) -> bool {
self.journal.state().is_working() && self.paid_breaks.current().is_some()
}
/// Changes the journal with `edit`, keeping the previous version for /// Changes the journal with `edit`, keeping the previous version for
/// undo, then saves. `edit` works on a copy: if it fails, or saving /// undo, then saves. `edit` works on a copy: if it fails, or saving
/// fails, nothing changes at all. /// fails, nothing changes at all.
@@ -681,6 +723,12 @@ impl Model {
self.undo.remove(0); self.undo.remove(0);
} }
self.redo.clear(); self.redo.clear();
// Stopping the shift ends a paid break; closing the day forgets them.
match self.journal.state() {
ShiftState::Idle => self.paid_breaks.clear(),
ShiftState::OnBreak { .. } => self.paid_breaks.finish(self.clock.now().timestamp()),
ShiftState::Working { .. } => {}
}
Ok(result) Ok(result)
} }
@@ -848,8 +896,9 @@ mod tests {
} }
/// Everything to look at: each screen, view, popup and hint. /// Everything to look at: each screen, view, popup and hint.
const TOUR: [&str; 16] = [ const TOUR: [&str; 17] = [
"", "",
"b",
"<Space>", "<Space>",
"<Esc>gd", "<Esc>gd",
"l", "l",
@@ -911,6 +960,22 @@ mod tests {
assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]); assert_eq!(dates_of_segments_at(&app, other_day, 18), vec![other_day]);
} }
#[test]
fn paid_breaks_keep_the_shift_running() {
let mut app = sample_app("paid-break");
let journal_before = app.model.journal.clone();
press(&mut app, "b");
assert!(app.model.on_paid_break());
assert_eq!(app.model.journal, journal_before, "the worked time isn't touched");
press(&mut app, "s");
assert!(!app.model.on_paid_break(), "s means back to focus");
assert!(app.model.journal.state().is_working());
// With paid breaks off, a pomodoro break is a plain pause.
press(&mut app, ":set nopaid_breaks<CR>b");
assert!(!app.model.journal.state().is_working());
}
#[test] #[test]
fn editing_through_the_keys() { fn editing_through_the_keys() {
let mut app = sample_app("editing"); let mut app = sample_app("editing");
+102 -8
View File
@@ -8,11 +8,52 @@
//! interruptions (shorter than the short break) don't reset it. //! interruptions (shorter than the short break) don't reset it.
//! - When a stretch reaches the focus length, a break is due: a short one, //! - 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. //! 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 jiff::{SignedDuration, Timestamp};
use crate::domain::{Journal, ShiftState}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PomodoroSettings { pub struct PomodoroSettings {
pub enabled: bool, pub enabled: bool,
@@ -30,7 +71,8 @@ pub enum Phase {
/// Working. `started` is when the current focus stretch began. /// Working. `started` is when the current focus stretch began.
Focus { started: Timestamp, focused: SignedDuration, goal: SignedDuration }, Focus { started: Timestamp, focused: SignedDuration, goal: SignedDuration },
/// On a break that began at `started`. `goal` is the suggested length. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -62,7 +104,12 @@ impl Pomodoro {
} }
/// The rhythm of the open work day, as of `now`. /// 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 = let mut result =
Pomodoro { phase: Phase::Off, completed: 0, long_break_every: settings.long_break_every }; Pomodoro { phase: Phase::Off, completed: 0, long_break_every: settings.long_break_every };
let Some(open_day) = journal.state().open_day() else { return result }; 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; 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 let mut pieces: Vec<(Timestamp, Timestamp)> = journal
.day(open_day) .day(open_day)
.map(|day| day.segments().iter().map(|segment| (segment.start(), segment.end())).collect()) .map(|day| day.segments().iter().map(|segment| (segment.start(), segment.end())).collect())
.unwrap_or_default(); .unwrap_or_default();
let working = journal.state().is_working();
if let ShiftState::Working { since, .. } = journal.state() { if let ShiftState::Working { since, .. } = journal.state() {
pieces.push((since, now.max(since))); 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 // Join pieces into focus stretches: a gap shorter than a short break
// is an interruption, not a 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; result.completed = stretches.iter().filter(|stretch| stretch.worked >= settings.focus).count() as u32;
let Some(last) = stretches.last() else { return result }; 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 } Phase::Focus { started: last.start, focused: last.worked, goal: settings.focus }
} else { } else {
// The break that follows a completed stretch is long every // 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), taken: now.duration_since(last.end),
goal: if long { settings.long_break } else { settings.short_break }, goal: if long { settings.long_break } else { settings.short_break },
long, long,
paid: on_paid_break,
} }
}; };
result 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 { struct Stretch {
start: Timestamp, start: Timestamp,
end: Timestamp, end: Timestamp,
@@ -145,7 +211,7 @@ mod tests {
journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap(); journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap();
journal.pause(at(9, 20).timestamp()).unwrap(); journal.pause(at(9, 20).timestamp()).unwrap();
journal.start(&at(9, 22), NEW_DAY_AFTER).unwrap(); // a 2 minute interruption 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!(rhythm.break_due());
assert_eq!( assert_eq!(
rhythm.phase, rhythm.phase,
@@ -162,14 +228,42 @@ mod tests {
let mut journal = Journal::default(); let mut journal = Journal::default();
journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap(); journal.start(&at(9, 0), NEW_DAY_AFTER).unwrap();
journal.pause(at(9, 25).timestamp()).unwrap(); journal.pause(at(9, 25).timestamp()).unwrap();
let first_break = pomodoro(&journal, at(9, 26).timestamp(), &SETTINGS); let none = PaidBreaks::default();
assert!(matches!(first_break.phase, Phase::Break { long: false, .. })); 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.start(&at(9, 30), NEW_DAY_AFTER).unwrap();
journal.pause(at(9, 55).timestamp()).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_eq!(second_break.completed, 2);
assert!(matches!(second_break.phase, Phase::Break { long: true, .. })); assert!(matches!(second_break.phase, Phase::Break { long: true, .. }));
assert!(second_break.break_over()); 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, pub export_folder: String,
// Reminders // Reminders
pub pomodoro: bool, pub pomodoro: bool,
pub paid_breaks: bool,
pub focus_length: SignedDuration, pub focus_length: SignedDuration,
pub short_break: SignedDuration, pub short_break: SignedDuration,
pub long_break: SignedDuration, pub long_break: SignedDuration,
@@ -97,6 +98,7 @@ impl Default for Settings {
export_format: ExportFormat::Csv, export_format: ExportFormat::Csv,
export_folder: String::new(), export_folder: String::new(),
pomodoro: true, pomodoro: true,
paid_breaks: true,
focus_length: SignedDuration::from_mins(25), focus_length: SignedDuration::from_mins(25),
short_break: SignedDuration::from_mins(5), short_break: SignedDuration::from_mins(5),
long_break: SignedDuration::from_mins(15), long_break: SignedDuration::from_mins(15),
@@ -538,6 +540,19 @@ pub static FIELDS: &[Field] = &[
Ok(()) 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 { Field {
section: "reminders", section: "reminders",
key: "focus_length", key: "focus_length",
+3
View File
@@ -48,6 +48,7 @@ pub enum Command {
EndDayAt, EndDayAt,
StartEarlier, StartEarlier,
StartLater, StartLater,
TakeBreak,
Snooze, Snooze,
// Recorded work // Recorded work
Edit, Edit,
@@ -124,6 +125,7 @@ pub const GLOBAL: &[Binding] = &[
bind("<Tab>", C::NextScreen, "next screen"), bind("<Tab>", C::NextScreen, "next screen"),
bind("<S-Tab>", C::PreviousScreen, "previous screen"), bind("<S-Tab>", C::PreviousScreen, "previous screen"),
bind("zz", C::Snooze, "snooze the break reminder"), 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><Space>", C::Toggle, "start / pause"),
bind("<Space>s", C::StartAt, "start at an earlier time..."), bind("<Space>s", C::StartAt, "start at an earlier time..."),
bind("<Space>p", C::PauseAt, "pause 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] = &[ 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("<CR>", C::Edit, "fix the session in progress"),
bind("i", 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"), bind("o", C::AddAfter, "add forgotten work to today"),
+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)"), (":set name :set noname", "turn an on/off setting on or off (name! flips it)"),
(":undo :redo", "undo / redo"), (":undo :redo", "undo / redo"),
(":snooze", "snooze the break reminder"), (":snooze", "snooze the break reminder"),
(":break", "take a pomodoro break / back from it"),
(":tracker :days :insights :settings", "go to a screen"), (":tracker :days :insights :settings", "go to a screen"),
(":help", "all keys"), (":help", "all keys"),
(":q", "quit"), (":q", "quit"),
@@ -125,6 +126,7 @@ pub fn parse(text: &str, ctx: &Context) -> Result<Action, String> {
"undo" | "u" => Ok(Action::Undo), "undo" | "u" => Ok(Action::Undo),
"redo" => Ok(Action::Redo), "redo" => Ok(Action::Redo),
"snooze" => Ok(Action::Snooze), "snooze" => Ok(Action::Snooze),
"break" => Ok(Action::TakeBreak),
"tracker" => Ok(Action::GoTo(Tab::Tracker)), "tracker" => Ok(Action::GoTo(Tab::Tracker)),
"days" => Ok(Action::GoTo(Tab::Days)), "days" => Ok(Action::GoTo(Tab::Days)),
"insights" => Ok(Action::GoTo(Tab::Insights)), "insights" => Ok(Action::GoTo(Tab::Insights)),
+31 -6
View File
@@ -82,7 +82,16 @@ impl Tracker {
let state = ctx.journal.state(); let state = ctx.journal.state();
let (symbol, text) = match state { let (symbol, text) = match state {
ShiftState::Working { day, since } => { ShiftState::Working { day, since } => {
("●", format!("Working since {} · work day {}", ctx.time(since), ctx.fmt.date_short(day))) 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 } => { ShiftState::OnBreak { day } => {
("◐", format!("On a break · work day {}", ctx.fmt.date_short(day))) ("◐", format!("On a break · work day {}", ctx.fmt.date_short(day)))
@@ -181,21 +190,37 @@ impl Tracker {
), ),
Style::new().fg(ctx.theme.warning).add_modifier(Modifier::BOLD), Style::new().fg(ctx.theme.warning).add_modifier(Modifier::BOLD),
), ),
Span::styled(" p pause · zz snooze", ctx.theme.muted()), Span::styled(
if settings.paid_breaks {
" b break · zz snooze"
} else {
" p pause · zz snooze"
},
ctx.theme.muted(),
),
]) ])
} }
}, },
Phase::Break { taken, goal, long, .. } if taken < goal => Line::from(vec![ Phase::Break { taken, goal, long, paid, .. } if taken < goal => Line::from(vec![
Span::styled(if long { "Long break " } else { "Break " }, ctx.theme.muted()), 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::raw(format!("{} / {} ", fmt.duration(taken), fmt.duration(goal))),
Span::styled(dots, ctx.theme.accent()), Span::styled(dots, ctx.theme.accent()),
Span::styled(if paid { " b back" } else { "" }, ctx.theme.muted()),
]), ]),
Phase::Break { goal, .. } => Line::from(vec![ Phase::Break { goal, paid, .. } => Line::from(vec![
Span::styled( Span::styled(
format!("Break's over ({}). Back when you're ready.", fmt.duration(goal)), format!("Break's over ({}). Back when you're ready.", fmt.duration(goal)),
ctx.theme.title(), ctx.theme.title(),
), ),
Span::styled(" s start", ctx.theme.muted()), Span::styled(if paid { " b back" } else { " s start" }, ctx.theme.muted()),
]), ]),
}; };
frame.render_widget(Paragraph::new(line).centered(), area); frame.render_widget(Paragraph::new(line).centered(), area);