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:
1 parent
fe5e2d9a7f
commit
49e14b820a
8 files changed
+233
-21
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user