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 |
| `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 `<Space>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.
+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,
+72 -7
View File
@@ -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",
"<Space>",
"<Esc>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_breaks<CR>b");
assert!(!app.model.journal.state().is_working());
}
#[test]
fn editing_through_the_keys() {
let mut app = sample_app("editing");
+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",
+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"),
+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)),
+31 -6
View File
@@ -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);