From 49e14b820a789aa9c3b7bc322c681731d32fc37f Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 25 Sep 2026 00:06:26 -0400 Subject: [PATCH] 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 --- README.md | 5 ++ src/action.rs | 3 + src/app.rs | 79 +++++++++++++++++++++--- src/reminders.rs | 110 +++++++++++++++++++++++++++++++--- src/settings.rs | 15 +++++ src/ui/keymap.rs | 3 + src/ui/popups/command_line.rs | 2 + src/ui/screens/tracker.rs | 37 ++++++++++-- 8 files changed, 233 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9ec6956..25ad3ae 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ in the settings. | `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 | @@ -60,6 +61,10 @@ settings, or with `w`. - 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. diff --git a/src/action.rs b/src/action.rs index 1b5b99b..cc647f7 100644 --- a/src/action.rs +++ b/src/action.rs @@ -41,6 +41,9 @@ pub enum Action { since: Timestamp, stopped: Option, }, + /// 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, diff --git a/src/app.rs b/src/app.rs index ec28696..421a0af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,7 +26,7 @@ use crate::action::Action; use crate::domain::{DomainError, Journal, ShiftState, StartOutcome, WorkDay}; use crate::export::clipboard::Clipboard; use crate::export::{self, Report, timesheet}; -use crate::reminders::{self, Phase}; +use crate::reminders::{self, PaidBreaks, Phase}; use crate::settings::{FIELDS, Settings}; use crate::storage::journal_file::{JournalStore, OutsideChange}; use crate::storage::paths::Paths; @@ -77,6 +77,8 @@ struct Model { paths: Paths, export_folder: PathBuf, nudges: Nudges, + /// Pomodoro breaks taken without pausing the shift. + paid_breaks: PaidBreaks, } /// What the reminders already said, so each is said only once. @@ -139,6 +141,7 @@ impl App { store, paths, nudges: Nudges::default(), + paid_breaks: PaidBreaks::default(), }; let mut app = App { model, @@ -356,6 +359,7 @@ impl App { Command::PauseAt => Some(popup(TimePrompt::new(Purpose::Pause))), Command::EndDayAt => Some(popup(TimePrompt::new(Purpose::EndDay))), Command::Snooze => Some(Action::Snooze), + Command::TakeBreak => Some(Action::TakeBreak), Command::Undo => Some(Action::Undo), Command::Redo => Some(Action::Redo), Command::Help => Some(Action::ShowHelp), @@ -397,6 +401,12 @@ impl App { } 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 => { let now = self.model.clock.punch(); let new_day_after = self.model.settings.new_day_after_break; @@ -468,6 +478,25 @@ impl App { }; 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 => { let until = self.model.clock.now().timestamp() + self.model.settings.snooze; self.model.nudges.snoozed_until = Some(until); @@ -607,7 +636,7 @@ impl App { 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 { Phase::Focus { started, focused, .. } if pomodoro.break_due() @@ -615,13 +644,16 @@ impl App { && 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) => { 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, }; @@ -659,12 +691,22 @@ impl Model { now, fmt: self.settings.formatter(), theme: &self.theme, - pomodoro: reminders::pomodoro(&self.journal, now.timestamp(), &self.settings.pomodoro()), + pomodoro: self.pomodoro(now.timestamp()), snoozed_until: self.nudges.snoozed_until, 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 /// undo, then saves. `edit` works on a copy: if it fails, or saving /// fails, nothing changes at all. @@ -681,6 +723,12 @@ impl Model { self.undo.remove(0); } 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) } @@ -848,8 +896,9 @@ mod tests { } /// Everything to look at: each screen, view, popup and hint. - const TOUR: [&str; 16] = [ + const TOUR: [&str; 17] = [ "", + "b", "", "gd", "l", @@ -911,6 +960,22 @@ mod tests { 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_breaksb"); + assert!(!app.model.journal.state().is_working()); + } + #[test] fn editing_through_the_keys() { let mut app = sample_app("editing"); diff --git a/src/reminders.rs b/src/reminders.rs index 3499220..f093c36 100644 --- a/src/reminders.rs +++ b/src/reminders.rs @@ -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, +} + +impl PaidBreaks { + pub fn current(&self) -> Option { + 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 + '_ { + 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)); + } } diff --git a/src/settings.rs b/src/settings.rs index 3625055..3c57c6e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -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", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 29a05b5..023ac03 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -48,6 +48,7 @@ pub enum Command { EndDayAt, StartEarlier, StartLater, + TakeBreak, Snooze, // Recorded work Edit, @@ -124,6 +125,7 @@ pub const GLOBAL: &[Binding] = &[ bind("", C::NextScreen, "next screen"), bind("", C::PreviousScreen, "previous screen"), bind("zz", C::Snooze, "snooze the break reminder"), + bind("zb", C::TakeBreak, "take a pomodoro break / back from it"), bind("", C::Toggle, "start / pause"), bind("s", C::StartAt, "start at an earlier time..."), bind("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("", 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"), diff --git a/src/ui/popups/command_line.rs b/src/ui/popups/command_line.rs index 9512be1..3a2a01c 100644 --- a/src/ui/popups/command_line.rs +++ b/src/ui/popups/command_line.rs @@ -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 { "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)), diff --git a/src/ui/screens/tracker.rs b/src/ui/screens/tracker.rs index 7fb0478..63d490d 100644 --- a/src/ui/screens/tracker.rs +++ b/src/ui/screens/tracker.rs @@ -82,7 +82,16 @@ impl Tracker { let state = ctx.journal.state(); let (symbol, text) = match state { 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 } => { ("◐", 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), ), - 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![ - Span::styled(if long { "Long break " } else { "Break " }, 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, .. } => Line::from(vec![ + 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(" 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);