Add pomodoro-style reminders
Advice only: it reads your real Start/Pause presses and says when a break (or getting back) fits the rhythm. Interruptions shorter than a short break don't reset a focus stretch; every Nth completed stretch earns a long break. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
97f834a299
commit
35d55c5122
2 files changed
+176
No files matched your search
@@ -7,6 +7,7 @@
|
||||
mod analysis;
|
||||
mod domain;
|
||||
mod export;
|
||||
mod reminders;
|
||||
mod time;
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Gentle nudges while you work: a pomodoro-style rhythm of focus and breaks.
|
||||
//!
|
||||
//! This is advice only: skyshift never starts or pauses on its own. It looks
|
||||
//! at what you actually did (your Start and Pause presses) and tells you when
|
||||
//! a break, or getting back to work, would fit the rhythm you chose:
|
||||
//!
|
||||
//! - A **focus stretch** is work since your last real break. Short
|
||||
//! 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.
|
||||
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use crate::domain::{Journal, ShiftState};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PomodoroSettings {
|
||||
pub enabled: bool,
|
||||
pub focus: SignedDuration,
|
||||
pub short_break: SignedDuration,
|
||||
pub long_break: SignedDuration,
|
||||
pub long_break_every: u32,
|
||||
}
|
||||
|
||||
/// Where you are in the focus/break rhythm.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// No work day open, or pomodoro turned off.
|
||||
Off,
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Pomodoro {
|
||||
pub phase: Phase,
|
||||
/// Focus stretches that reached the focus length in the open work day.
|
||||
pub completed: u32,
|
||||
pub long_break_every: u32,
|
||||
}
|
||||
|
||||
impl Pomodoro {
|
||||
/// Worked the whole focus length: time for a break.
|
||||
pub fn break_due(&self) -> bool {
|
||||
matches!(self.phase, Phase::Focus { focused, goal, .. } if focused >= goal)
|
||||
}
|
||||
|
||||
/// The suggested break is over: time to get back, when you're ready.
|
||||
pub fn break_over(&self) -> bool {
|
||||
matches!(self.phase, Phase::Break { taken, goal, .. } if taken >= goal)
|
||||
}
|
||||
|
||||
/// How far into the current cycle of `long_break_every` stretches you are.
|
||||
pub fn cycle_position(&self) -> u32 {
|
||||
match self.long_break_every {
|
||||
0 => 0,
|
||||
every => self.completed % every,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rhythm of the open work day, as of `now`.
|
||||
pub fn pomodoro(journal: &Journal, 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 };
|
||||
if !settings.enabled || settings.focus <= SignedDuration::ZERO {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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();
|
||||
if let ShiftState::Working { since, .. } = journal.state() {
|
||||
pieces.push((since, now.max(since)));
|
||||
}
|
||||
|
||||
// Join pieces into focus stretches: a gap shorter than a short break
|
||||
// is an interruption, not a break.
|
||||
let mut stretches: Vec<Stretch> = Vec::new();
|
||||
for (start, end) in pieces {
|
||||
match stretches.last_mut() {
|
||||
Some(stretch) if start.duration_since(stretch.end) < settings.short_break => {
|
||||
stretch.end = end;
|
||||
stretch.worked += end.duration_since(start);
|
||||
}
|
||||
_ => stretches.push(Stretch { start, end, worked: end.duration_since(start) }),
|
||||
}
|
||||
}
|
||||
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() {
|
||||
Phase::Focus { started: last.start, focused: last.worked, goal: settings.focus }
|
||||
} else {
|
||||
// The break that follows a completed stretch is long every
|
||||
// `long_break_every` stretches; after an unfinished one, it's short.
|
||||
let finished = last.worked >= settings.focus;
|
||||
let long = finished
|
||||
&& settings.long_break_every > 0
|
||||
&& result.completed.is_multiple_of(settings.long_break_every);
|
||||
Phase::Break {
|
||||
started: last.end,
|
||||
taken: now.duration_since(last.end),
|
||||
goal: if long { settings.long_break } else { settings.short_break },
|
||||
long,
|
||||
}
|
||||
};
|
||||
result
|
||||
}
|
||||
|
||||
struct Stretch {
|
||||
start: Timestamp,
|
||||
end: Timestamp,
|
||||
worked: SignedDuration,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jiff::Zoned;
|
||||
use jiff::civil::date;
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
const SETTINGS: PomodoroSettings = PomodoroSettings {
|
||||
enabled: true,
|
||||
focus: SignedDuration::from_mins(25),
|
||||
short_break: SignedDuration::from_mins(5),
|
||||
long_break: SignedDuration::from_mins(15),
|
||||
long_break_every: 2,
|
||||
};
|
||||
const NEW_DAY_AFTER: SignedDuration = SignedDuration::from_hours(8);
|
||||
|
||||
fn at(hour: i8, minute: i8) -> Zoned {
|
||||
date(2026, 9, 24).at(hour, minute, 0, 0).to_zoned(TimeZone::UTC).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_interruption_does_not_reset_focus() {
|
||||
let mut journal = Journal::default();
|
||||
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);
|
||||
assert!(rhythm.break_due());
|
||||
assert_eq!(
|
||||
rhythm.phase,
|
||||
Phase::Focus {
|
||||
started: at(9, 0).timestamp(),
|
||||
focused: SignedDuration::from_mins(25),
|
||||
goal: SETTINGS.focus
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_second_stretch_earns_a_long_break() {
|
||||
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, .. }));
|
||||
|
||||
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);
|
||||
assert_eq!(second_break.completed, 2);
|
||||
assert!(matches!(second_break.phase, Phase::Break { long: true, .. }));
|
||||
assert!(second_break.break_over());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user