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:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:22 -04:00
1 parent 97f834a299
commit 35d55c5122
2 files changed
+176

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
mod analysis;
mod domain;
mod export;
mod reminders;
mod time;
fn main() {}
+175
View File
@@ -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());
}
}