Add session-owned Pomodoro cycles and controls
Add configurable work and break intervals, pause/resume, CLI and activity-popup controls, and silent completion alerts. Use CLOCK_BOOTTIME deadlines so running timers include suspend without consuming paused time. Refs #31
This commit is contained in:
1 parent
53ba599918
commit
2c9bb85f2c
18 files changed
+1209
-16
No files matched your search
@@ -1,5 +1,7 @@
|
||||
//! Typed activity data and deterministic policy. The host supplies verified owners
|
||||
//! and suspend-aware milliseconds; this crate performs no I/O or rendering.
|
||||
pub mod pomodoro;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,6 +34,7 @@ pub enum Value {
|
||||
Progress(f32),
|
||||
Text(String),
|
||||
Countdown { remaining_ms: u64, sampled_at: Time },
|
||||
PausedCountdown { remaining_ms: u64 },
|
||||
}
|
||||
|
||||
impl Value {
|
||||
@@ -41,6 +44,7 @@ impl Value {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
|
||||
Self::PausedCountdown { remaining_ms } => Some(*remaining_ms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -109,6 +113,9 @@ impl Data {
|
||||
return Err("progress must be finite and between zero and one");
|
||||
}
|
||||
Value::Text(value) => text(value)?,
|
||||
Value::PausedCountdown { remaining_ms } if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 => {
|
||||
return Err("countdown exceeds seven days");
|
||||
}
|
||||
Value::Countdown {
|
||||
remaining_ms,
|
||||
sampled_at,
|
||||
@@ -121,10 +128,10 @@ impl Data {
|
||||
_ => {}
|
||||
}
|
||||
match &self.template {
|
||||
Template::Timer if !matches!(self.value, Value::Countdown { .. }) => {
|
||||
Template::Timer if !matches!(self.value, Value::Countdown { .. } | Value::PausedCountdown { .. }) => {
|
||||
return Err("timer requires a countdown");
|
||||
}
|
||||
Template::Progress if matches!(self.value, Value::Countdown { .. }) => {
|
||||
Template::Progress if matches!(self.value, Value::Countdown { .. } | Value::PausedCountdown { .. }) => {
|
||||
return Err("countdown requires timer template");
|
||||
}
|
||||
Template::Media { artist, .. } => {
|
||||
@@ -242,6 +249,10 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self) -> u64 {
|
||||
self.session
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
|
||||
self.entries.values()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! One session's timer. The host supplies CLOCK_BOOTTIME samples and delivers
|
||||
//! returned completions; neither presentation nor client connections own time.
|
||||
use crate::Time;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub work_minutes: u16,
|
||||
pub short_break_minutes: u16,
|
||||
pub long_break_minutes: u16,
|
||||
pub intervals: u8,
|
||||
pub auto_advance: bool,
|
||||
pub notifications: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
work_minutes: 25,
|
||||
short_break_minutes: 5,
|
||||
long_break_minutes: 15,
|
||||
intervals: 4,
|
||||
auto_advance: false,
|
||||
notifications: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if !(1..=180).contains(&self.work_minutes)
|
||||
|| !(1..=60).contains(&self.short_break_minutes)
|
||||
|| !(1..=120).contains(&self.long_break_minutes)
|
||||
|| !(1..=12).contains(&self.intervals)
|
||||
{
|
||||
return Err(
|
||||
"Pomodoro requires work 1–180 min, short break 1–60 min, long break 1–120 min, and 1–12 intervals",
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn duration(&self, phase: Phase) -> u64 {
|
||||
u64::from(match phase {
|
||||
Phase::Work => self.work_minutes,
|
||||
Phase::ShortBreak => self.short_break_minutes,
|
||||
Phase::LongBreak => self.long_break_minutes,
|
||||
}) * 60_000
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Phase {
|
||||
Work,
|
||||
ShortBreak,
|
||||
LongBreak,
|
||||
}
|
||||
impl Phase {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Work => "Work",
|
||||
Self::ShortBreak => "Short break",
|
||||
Self::LongBreak => "Long break",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Status {
|
||||
Running,
|
||||
Paused,
|
||||
Ready,
|
||||
Finished,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct Session {
|
||||
pub settings: Settings,
|
||||
pub phase: Phase,
|
||||
pub status: Status,
|
||||
pub completed_work: u8,
|
||||
pub remaining_ms: u64,
|
||||
pub deadline: Option<Time>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Completion {
|
||||
pub phase: Phase,
|
||||
pub next: Option<Phase>,
|
||||
}
|
||||
impl Session {
|
||||
pub fn start(settings: Settings, now: Time) -> Result<Self, &'static str> {
|
||||
settings.validate()?;
|
||||
let remaining_ms = settings.duration(Phase::Work);
|
||||
Ok(Self {
|
||||
settings,
|
||||
phase: Phase::Work,
|
||||
status: Status::Running,
|
||||
completed_work: 0,
|
||||
remaining_ms,
|
||||
deadline: Some(now.saturating_add(remaining_ms)),
|
||||
})
|
||||
}
|
||||
pub fn remaining(&self, now: Time) -> u64 {
|
||||
self.deadline
|
||||
.map_or(self.remaining_ms, |deadline| deadline.saturating_sub(now))
|
||||
}
|
||||
fn next_phase(&self) -> Option<Phase> {
|
||||
match self.phase {
|
||||
Phase::Work if self.completed_work >= self.settings.intervals => Some(Phase::LongBreak),
|
||||
Phase::Work => Some(Phase::ShortBreak),
|
||||
Phase::ShortBreak => Some(Phase::Work),
|
||||
Phase::LongBreak => None,
|
||||
}
|
||||
}
|
||||
/// Completes at most the interval that was actually running. After suspend,
|
||||
/// automatic advancement starts the next interval at this observation, rather
|
||||
/// than counting unattended work intervals during sleep.
|
||||
pub fn advance(&mut self, now: Time) -> Option<Completion> {
|
||||
if self.deadline.is_none_or(|deadline| now < deadline) {
|
||||
return None;
|
||||
}
|
||||
if self.phase == Phase::Work {
|
||||
self.completed_work += 1;
|
||||
}
|
||||
let completion = Completion {
|
||||
phase: self.phase,
|
||||
next: self.next_phase(),
|
||||
};
|
||||
self.select(completion.next, now, self.settings.auto_advance);
|
||||
Some(completion)
|
||||
}
|
||||
fn select(&mut self, phase: Option<Phase>, now: Time, running: bool) {
|
||||
self.deadline = None;
|
||||
if let Some(phase) = phase {
|
||||
self.phase = phase;
|
||||
self.remaining_ms = self.settings.duration(phase);
|
||||
self.status = if running { Status::Running } else { Status::Ready };
|
||||
if running {
|
||||
self.deadline = Some(now.saturating_add(self.remaining_ms));
|
||||
}
|
||||
} else {
|
||||
self.remaining_ms = 0;
|
||||
self.status = Status::Finished;
|
||||
}
|
||||
}
|
||||
pub fn pause(&mut self, now: Time) -> Result<(), &'static str> {
|
||||
match self.status {
|
||||
Status::Paused => Ok(()),
|
||||
Status::Running => {
|
||||
self.remaining_ms = self.remaining(now);
|
||||
self.deadline = None;
|
||||
self.status = Status::Paused;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("No running Pomodoro interval"),
|
||||
}
|
||||
}
|
||||
pub fn resume(&mut self, now: Time) -> Result<(), &'static str> {
|
||||
match self.status {
|
||||
Status::Running => Ok(()),
|
||||
Status::Paused | Status::Ready => {
|
||||
self.deadline = Some(now.saturating_add(self.remaining_ms));
|
||||
self.status = Status::Running;
|
||||
Ok(())
|
||||
}
|
||||
Status::Finished => Err("Start a new Pomodoro session"),
|
||||
}
|
||||
}
|
||||
/// Skipping never completes work or emits a completion. A Ready interval is
|
||||
/// already the next phase, so Next starts it rather than skipping twice.
|
||||
pub fn next(&mut self, now: Time) -> Result<(), &'static str> {
|
||||
match self.status {
|
||||
Status::Finished => Err("Start a new Pomodoro session"),
|
||||
Status::Ready => self.resume(now),
|
||||
_ => {
|
||||
self.select(self.next_phase(), now, true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pauses_preserve_milliseconds_and_do_not_count_sleep() {
|
||||
let mut session = Session::start(Settings::default(), 100).unwrap();
|
||||
session.pause(1_337).unwrap();
|
||||
assert_eq!(session.remaining(100_000_000), 1_498_763);
|
||||
assert!(session.advance(100_000_000).is_none());
|
||||
session.resume(100_000_000).unwrap();
|
||||
assert_eq!(session.deadline, Some(101_498_763));
|
||||
session.pause(100_000_001).unwrap();
|
||||
assert_eq!(session.remaining_ms, 1_498_762);
|
||||
assert_eq!(session.completed_work, 0);
|
||||
}
|
||||
#[test]
|
||||
fn boundaries_and_long_break_are_exactly_once() {
|
||||
let mut session = Session::start(
|
||||
Settings {
|
||||
intervals: 2,
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
for (finished, next, count) in [
|
||||
(Phase::Work, Some(Phase::ShortBreak), 1),
|
||||
(Phase::ShortBreak, Some(Phase::Work), 1),
|
||||
(Phase::Work, Some(Phase::LongBreak), 2),
|
||||
(Phase::LongBreak, None, 2),
|
||||
] {
|
||||
let deadline = session.deadline.unwrap();
|
||||
assert!(session.advance(deadline - 1).is_none());
|
||||
assert_eq!(session.advance(deadline), Some(Completion { phase: finished, next }));
|
||||
assert_eq!(session.completed_work, count);
|
||||
assert!(session.advance(deadline + 99_000_000).is_none());
|
||||
if next.is_some() {
|
||||
assert_eq!(session.status, Status::Ready);
|
||||
session.next(deadline).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(session.status, Status::Finished);
|
||||
assert!(session.resume(0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn skipping_and_cancelling_never_complete_work() {
|
||||
let mut session = Session::start(Settings::default(), 0).unwrap();
|
||||
session.next(50).unwrap();
|
||||
assert_eq!(session.phase, Phase::ShortBreak);
|
||||
session.next(60).unwrap();
|
||||
assert_eq!(session.phase, Phase::Work);
|
||||
assert_eq!(session.completed_work, 0);
|
||||
session.pause(70).unwrap();
|
||||
session.next(80).unwrap();
|
||||
assert_eq!(session.completed_work, 0);
|
||||
}
|
||||
#[test]
|
||||
fn suspend_finishes_only_the_running_interval() {
|
||||
let mut session = Session::start(
|
||||
Settings {
|
||||
auto_advance: true,
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session.advance(100_000_000).unwrap().phase, Phase::Work);
|
||||
assert_eq!(session.completed_work, 1);
|
||||
assert_eq!(session.deadline, Some(100_300_000));
|
||||
assert!(session.advance(100_000_000).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn invalid_settings_are_rejected() {
|
||||
for settings in [
|
||||
Settings {
|
||||
work_minutes: 0,
|
||||
..Default::default()
|
||||
},
|
||||
Settings {
|
||||
short_break_minutes: 61,
|
||||
..Default::default()
|
||||
},
|
||||
Settings {
|
||||
long_break_minutes: 121,
|
||||
..Default::default()
|
||||
},
|
||||
Settings {
|
||||
intervals: 13,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(Session::start(settings, 0).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ pub struct Settings {
|
||||
pub start_delay_ms: u64,
|
||||
pub result_flash_ms: u64,
|
||||
pub sources: Sources,
|
||||
pub pomodoro: ferese_activities::pomodoro::Settings,
|
||||
pub rules: Vec<Rule>,
|
||||
}
|
||||
impl Default for Settings {
|
||||
@@ -49,6 +50,7 @@ impl Default for Settings {
|
||||
start_delay_ms: 300,
|
||||
result_flash_ms: 1_500,
|
||||
sources: Sources::default(),
|
||||
pomodoro: Default::default(),
|
||||
rules: builtins(),
|
||||
}
|
||||
}
|
||||
@@ -181,6 +183,7 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.pomodoro.validate().map_err(str::to_owned)?;
|
||||
if !(1..=2).contains(&self.max_visible)
|
||||
|| !(1..=128).contains(&self.max_total)
|
||||
|| self.start_delay_ms > 60_000
|
||||
@@ -282,6 +285,7 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
|
||||
| "start-delay-ms"
|
||||
| "result-flash-ms"
|
||||
| "sources"
|
||||
| "pomodoro"
|
||||
) {
|
||||
return Err(diagnostic(child, "unknown activity node"));
|
||||
}
|
||||
@@ -352,6 +356,27 @@ fn builtins() -> Vec<Rule> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn pomodoro_settings_are_strict_and_validate_duration_ranges() {
|
||||
let doc=crate::Document::parse("activities { pomodoro { work-minutes 40; short-break-minutes 7; long-break-minutes 20; intervals 3; auto-advance #true; notifications #false; }; }").unwrap();
|
||||
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
|
||||
assert_eq!(settings.pomodoro.work_minutes, 40);
|
||||
assert_eq!(settings.pomodoro.intervals, 3);
|
||||
assert!(settings.pomodoro.auto_advance);
|
||||
assert!(!settings.pomodoro.notifications);
|
||||
for invalid in [
|
||||
"work-minutes 0",
|
||||
"short-break-minutes 61",
|
||||
"long-break-minutes 121",
|
||||
"intervals 13",
|
||||
"work-minuts 20",
|
||||
] {
|
||||
assert!(
|
||||
crate::Document::parse(&format!("activities {{ pomodoro {{ {invalid}; }}; }}")).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn settings_are_opt_in_and_matching_discards_nonmatches() {
|
||||
let settings = Settings::default();
|
||||
assert!(!settings.sources.shell_hook);
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct Snapshot {
|
||||
pub activities: Vec<Activity>,
|
||||
#[serde(default)]
|
||||
pub windows: Vec<ActivityWindow>,
|
||||
pub pomodoro: Option<Pomodoro>,
|
||||
}
|
||||
impl Default for Snapshot {
|
||||
fn default() -> Self {
|
||||
@@ -97,6 +98,34 @@ impl Default for Snapshot {
|
||||
limits: Limits::default(),
|
||||
activities: vec![],
|
||||
windows: vec![],
|
||||
pomodoro: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PomodoroAction {
|
||||
Start,
|
||||
Pause,
|
||||
Resume,
|
||||
Next,
|
||||
Stop,
|
||||
Reset,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PomodoroRequest {
|
||||
pub version: u32,
|
||||
pub action: PomodoroAction,
|
||||
/// Popup controls carry the displayed generation, so a delayed click cannot
|
||||
/// affect a replacement session. CLI commands may address the current one.
|
||||
pub session: Option<Id>,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Pomodoro {
|
||||
pub id: Id,
|
||||
pub timer: ferese_activities::pomodoro::Session,
|
||||
}
|
||||
Reference in new issue
Block a user