From 35d55c51228afa93960b4a549184ee5606b28b1e Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 24 Sep 2026 02:59:22 -0400 Subject: [PATCH] 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 --- src/main.rs | 1 + src/reminders.rs | 175 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/reminders.rs diff --git a/src/main.rs b/src/main.rs index af0a032..061bfe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod analysis; mod domain; mod export; +mod reminders; mod time; fn main() {} diff --git a/src/reminders.rs b/src/reminders.rs new file mode 100644 index 0000000..3499220 --- /dev/null +++ b/src/reminders.rs @@ -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 = 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()); + } +}