a lightweight, interval-based utility to combat digital strain through "Ma" (intentional pauses) for the eyes and body.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add profile type presets

+341
+82
src/timer/profile.rs
··· 1 + use std::time::Duration; 2 + 3 + use crate::config::{BreakLevelConfig, BreakModeConfig, LongBreakConfig, ProfileConfig}; 4 + 5 + #[derive(Debug, Clone, PartialEq)] 6 + pub enum BreakMode { 7 + Reminder, 8 + Enforced, 9 + } 10 + 11 + #[derive(Debug, Clone)] 12 + pub struct BreakLevel { 13 + pub work_duration: Duration, 14 + pub break_duration: Duration, 15 + pub label: String, 16 + } 17 + 18 + #[derive(Debug, Clone)] 19 + pub struct LongBreakTrigger { 20 + /// Number of top-level work cycles before a long break. 21 + pub after_cycles: u32, 22 + /// If idle gap exceeds this, cycle counter resets. 23 + pub max_cycle_gap: Duration, 24 + pub break_duration: Duration, 25 + pub label: String, 26 + } 27 + 28 + #[derive(Debug, Clone)] 29 + pub struct Profile { 30 + pub id: String, 31 + pub name: String, 32 + pub mode: BreakMode, 33 + pub snooze_duration: Duration, 34 + pub idle_threshold: Duration, 35 + pub idle_detection_enabled: bool, 36 + /// Sorted ascending by work_duration. Scheduler fires the highest-priority 37 + /// (longest interval) level whose work elapsed time is due. 38 + pub levels: Vec<BreakLevel>, 39 + pub long_break: Option<LongBreakTrigger>, 40 + } 41 + 42 + impl Profile { 43 + pub fn from_config(id: &str, cfg: &ProfileConfig) -> Self { 44 + let mut levels: Vec<BreakLevel> = cfg 45 + .levels 46 + .iter() 47 + .map(|l: &BreakLevelConfig| BreakLevel { 48 + work_duration: Duration::from_secs(l.work_secs), 49 + break_duration: Duration::from_secs(l.break_secs), 50 + label: l.label.clone(), 51 + }) 52 + .collect(); 53 + // Ensure ascending order so scheduler can use index priority correctly. 54 + levels.sort_by_key(|l| l.work_duration); 55 + 56 + let long_break = cfg.long_break.as_ref().map(|lb: &LongBreakConfig| LongBreakTrigger { 57 + after_cycles: lb.after_cycles, 58 + max_cycle_gap: Duration::from_secs(lb.max_cycle_gap_secs), 59 + break_duration: Duration::from_secs(lb.break_secs), 60 + label: lb.label.clone(), 61 + }); 62 + 63 + Self { 64 + id: id.to_string(), 65 + name: cfg.name.clone(), 66 + mode: match cfg.mode { 67 + BreakModeConfig::Reminder => BreakMode::Reminder, 68 + BreakModeConfig::Enforced => BreakMode::Enforced, 69 + }, 70 + snooze_duration: Duration::from_secs(cfg.snooze_secs), 71 + idle_threshold: Duration::from_secs(cfg.idle_threshold_secs), 72 + idle_detection_enabled: cfg.idle_detection_enabled, 73 + levels, 74 + long_break, 75 + } 76 + } 77 + 78 + /// The primary (top-level / longest) break level — used for cycle counting. 79 + pub fn primary_level(&self) -> &BreakLevel { 80 + self.levels.last().expect("profile must have at least one level") 81 + } 82 + }
+259
src/timer/scheduler.rs
··· 1 + use std::time::{Duration, Instant}; 2 + 3 + use super::profile::{BreakLevel, Profile}; 4 + 5 + /// Which break to fire, and its details. 6 + #[derive(Debug, Clone)] 7 + pub struct ScheduledBreak { 8 + pub break_duration: Duration, 9 + pub label: String, 10 + pub is_long_break: bool, 11 + /// Index into Profile::levels (None for long break). 12 + pub level_index: Option<usize>, 13 + } 14 + 15 + /// Tracks elapsed work time and cycle counts to determine the next break. 16 + pub struct Scheduler { 17 + profile: Profile, 18 + /// Elapsed work time since the last break (or session start). 19 + work_elapsed: Duration, 20 + /// How many primary-level (longest interval) cycles have completed. 21 + primary_cycle_count: u32, 22 + /// When the last break ended (for cycle gap detection). 23 + last_break_ended: Option<Instant>, 24 + } 25 + 26 + impl Scheduler { 27 + pub fn new(profile: Profile) -> Self { 28 + Self { 29 + profile, 30 + work_elapsed: Duration::ZERO, 31 + primary_cycle_count: 0, 32 + last_break_ended: None, 33 + } 34 + } 35 + 36 + pub fn profile(&self) -> &Profile { 37 + &self.profile 38 + } 39 + 40 + pub fn replace_profile(&mut self, profile: Profile) { 41 + self.profile = profile; 42 + self.work_elapsed = Duration::ZERO; 43 + self.primary_cycle_count = 0; 44 + } 45 + 46 + /// Advance the work timer by `delta`. Returns a break to fire if one is due. 47 + pub fn tick(&mut self, delta: Duration) -> Option<ScheduledBreak> { 48 + self.work_elapsed += delta; 49 + self.due_break() 50 + } 51 + 52 + /// Force the next scheduled break to fire immediately (tray "skip to next break"). 53 + pub fn skip_to_next_break(&mut self) -> Option<ScheduledBreak> { 54 + // Find the soonest upcoming break level and snap elapsed time to it. 55 + let next = self.next_break_level_index(); 56 + if let Some(idx) = next { 57 + self.work_elapsed = self.profile.levels[idx].work_duration; 58 + } else if let Some(lb) = &self.profile.long_break { 59 + // All regular levels done; snap to long break threshold. 60 + let primary_work = self.profile.primary_level().work_duration; 61 + let cycles_needed = lb.after_cycles.saturating_sub(self.primary_cycle_count); 62 + self.work_elapsed = primary_work * cycles_needed; 63 + } 64 + self.due_break() 65 + } 66 + 67 + /// Called when a break finishes. Updates cycle counts and resets elapsed work. 68 + pub fn record_break_completed(&mut self, was_long: bool) { 69 + let now = Instant::now(); 70 + 71 + if !was_long { 72 + // Count primary-level cycle completions. 73 + let primary_dur = self.profile.primary_level().work_duration; 74 + if self.work_elapsed >= primary_dur { 75 + // Check if the gap since last break is within the long-break window. 76 + let gap_ok = self.last_break_ended.map_or(true, |t| { 77 + self.profile 78 + .long_break 79 + .as_ref() 80 + .map_or(true, |lb| now.duration_since(t) <= lb.max_cycle_gap) 81 + }); 82 + 83 + if gap_ok { 84 + self.primary_cycle_count += 1; 85 + } else { 86 + self.primary_cycle_count = 1; // reset, this is the first valid cycle 87 + } 88 + } 89 + } else { 90 + self.primary_cycle_count = 0; 91 + } 92 + 93 + self.work_elapsed = Duration::ZERO; 94 + self.last_break_ended = Some(now); 95 + } 96 + 97 + /// Called when idle is detected (natural break). Optionally resets cycle gap. 98 + pub fn record_idle_break(&mut self, idle_duration: Duration) { 99 + if let Some(lb) = &self.profile.long_break { 100 + if idle_duration >= lb.max_cycle_gap { 101 + self.primary_cycle_count = 0; 102 + } 103 + } 104 + self.work_elapsed = Duration::ZERO; 105 + self.last_break_ended = Some(Instant::now()); 106 + } 107 + 108 + pub fn work_elapsed(&self) -> Duration { 109 + self.work_elapsed 110 + } 111 + 112 + pub fn primary_cycle_count(&self) -> u32 { 113 + self.primary_cycle_count 114 + } 115 + 116 + /// Seconds until the next break fires (for tray label). 117 + pub fn secs_until_next_break(&self) -> u64 { 118 + if let Some(idx) = self.next_break_level_index() { 119 + let due = self.profile.levels[idx].work_duration; 120 + due.saturating_sub(self.work_elapsed).as_secs() 121 + } else if let Some(lb) = &self.profile.long_break { 122 + let primary_dur = self.profile.primary_level().work_duration; 123 + let cycles_remaining = lb.after_cycles.saturating_sub(self.primary_cycle_count); 124 + let work_needed = primary_dur * cycles_remaining; 125 + work_needed.saturating_sub(self.work_elapsed).as_secs() 126 + } else { 127 + 0 128 + } 129 + } 130 + 131 + // --- Private helpers --- 132 + 133 + fn due_break(&mut self) -> Option<ScheduledBreak> { 134 + // Check long break first (highest priority when all cycles are done). 135 + if let Some(lb) = &self.profile.long_break { 136 + let primary_dur = self.profile.primary_level().work_duration; 137 + if self.primary_cycle_count >= lb.after_cycles 138 + && self.work_elapsed >= primary_dur 139 + { 140 + return Some(ScheduledBreak { 141 + break_duration: lb.break_duration, 142 + label: lb.label.clone(), 143 + is_long_break: true, 144 + level_index: None, 145 + }); 146 + } 147 + } 148 + 149 + // Find the highest-priority (longest interval) level that is due. 150 + for (idx, level) in self.profile.levels.iter().enumerate().rev() { 151 + if self.work_elapsed >= level.work_duration { 152 + return Some(ScheduledBreak { 153 + break_duration: level.break_duration, 154 + label: level.label.clone(), 155 + is_long_break: false, 156 + level_index: Some(idx), 157 + }); 158 + } 159 + } 160 + None 161 + } 162 + 163 + /// Index of the soonest upcoming break level (the one with the smallest remaining time). 164 + fn next_break_level_index(&self) -> Option<usize> { 165 + self.profile 166 + .levels 167 + .iter() 168 + .enumerate() 169 + .filter(|(_, l): &(usize, &BreakLevel)| self.work_elapsed < l.work_duration) 170 + .min_by_key(|(_, l)| l.work_duration.saturating_sub(self.work_elapsed)) 171 + .map(|(i, _)| i) 172 + } 173 + } 174 + 175 + #[cfg(test)] 176 + mod tests { 177 + use super::*; 178 + use crate::timer::profile::{BreakLevel, BreakMode, LongBreakTrigger, Profile}; 179 + 180 + fn make_profile(levels: Vec<(u64, u64)>, long: Option<(u32, u64, u64)>) -> Profile { 181 + Profile { 182 + id: "test".to_string(), 183 + name: "Test".to_string(), 184 + mode: BreakMode::Reminder, 185 + snooze_duration: Duration::from_secs(300), 186 + idle_threshold: Duration::from_secs(300), 187 + idle_detection_enabled: true, 188 + levels: levels 189 + .into_iter() 190 + .map(|(w, b)| BreakLevel { 191 + work_duration: Duration::from_secs(w), 192 + break_duration: Duration::from_secs(b), 193 + label: format!("{}s break", b), 194 + }) 195 + .collect(), 196 + long_break: long.map(|(cycles, gap, dur)| LongBreakTrigger { 197 + after_cycles: cycles, 198 + max_cycle_gap: Duration::from_secs(gap), 199 + break_duration: Duration::from_secs(dur), 200 + label: "Long break".to_string(), 201 + }), 202 + } 203 + } 204 + 205 + #[test] 206 + fn single_level_fires_at_interval() { 207 + let mut s = Scheduler::new(make_profile(vec![(600, 300)], None)); 208 + assert!(s.tick(Duration::from_secs(599)).is_none()); 209 + let b = s.tick(Duration::from_secs(1)).unwrap(); 210 + assert!(!b.is_long_break); 211 + assert_eq!(b.break_duration, Duration::from_secs(300)); 212 + } 213 + 214 + #[test] 215 + fn hierarchical_higher_priority_wins_at_alignment() { 216 + // levels: 10 min micro, 60 min regular 217 + let mut s = Scheduler::new(make_profile(vec![(600, 15), (3600, 600)], None)); 218 + // At 60 min, both fire — top level (3600) should win 219 + let b = s.tick(Duration::from_secs(3600)).unwrap(); 220 + assert_eq!(b.break_duration, Duration::from_secs(600)); 221 + } 222 + 223 + #[test] 224 + fn micro_fires_before_regular() { 225 + let mut s = Scheduler::new(make_profile(vec![(600, 15), (3600, 600)], None)); 226 + let b = s.tick(Duration::from_secs(600)).unwrap(); 227 + assert_eq!(b.break_duration, Duration::from_secs(15)); 228 + } 229 + 230 + #[test] 231 + fn long_break_fires_after_n_cycles() { 232 + let mut s = Scheduler::new(make_profile(vec![(600, 60)], Some((3, 1800, 1800)))); 233 + for _ in 0..3 { 234 + s.tick(Duration::from_secs(600)); 235 + s.record_break_completed(false); 236 + } 237 + let b = s.tick(Duration::from_secs(600)).unwrap(); 238 + assert!(b.is_long_break); 239 + assert_eq!(b.break_duration, Duration::from_secs(1800)); 240 + } 241 + 242 + #[test] 243 + fn long_break_cycle_resets_on_large_gap() { 244 + let mut s = Scheduler::new(make_profile(vec![(600, 60)], Some((3, 1800, 1800)))); 245 + s.tick(Duration::from_secs(600)); 246 + s.record_break_completed(false); 247 + assert_eq!(s.primary_cycle_count(), 1); 248 + // Simulate idle longer than max_cycle_gap 249 + s.record_idle_break(Duration::from_secs(1801)); 250 + assert_eq!(s.primary_cycle_count(), 0); 251 + } 252 + 253 + #[test] 254 + fn secs_until_next_break_counts_down() { 255 + let mut s = Scheduler::new(make_profile(vec![(600, 60)], None)); 256 + s.tick(Duration::from_secs(100)); 257 + assert_eq!(s.secs_until_next_break(), 500); 258 + } 259 + }