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.

fix: track elapsed time for multiple break intervals

+239 -71
+1
.gitignore
··· 1 1 /target 2 2 doc/ 3 + .*/
+78 -13
src/timer/mod.rs
··· 47 47 Resumed, 48 48 } 49 49 50 - #[derive(Debug, Clone, PartialEq)] 50 + #[derive(Debug, Clone)] 51 51 enum TimerState { 52 52 Working, 53 - Breaking { snooze_used: bool, is_long: bool }, 54 - Snoozed { until: Instant }, 55 - ManualPause { until: Option<Instant> }, 53 + /// `fired_index` is the scheduler level that triggered this break, so 54 + /// record_break_completed knows which counter to reset. 55 + Breaking { 56 + snooze_used: bool, 57 + is_long: bool, 58 + fired_index: usize, 59 + }, 60 + Snoozed { 61 + until: Instant, 62 + }, 63 + ManualPause { 64 + until: Option<Instant>, 65 + }, 56 66 IdlePaused, 57 67 } 58 68 69 + /// Manual PartialEq — Instant does not implement PartialEq. 70 + impl PartialEq for TimerState { 71 + fn eq(&self, other: &Self) -> bool { 72 + match (self, other) { 73 + (Self::Working, Self::Working) => true, 74 + (Self::IdlePaused, Self::IdlePaused) => true, 75 + ( 76 + Self::Breaking { 77 + snooze_used: a, 78 + is_long: b, 79 + fired_index: c, 80 + }, 81 + Self::Breaking { 82 + snooze_used: d, 83 + is_long: e, 84 + fired_index: f, 85 + }, 86 + ) => a == d && b == e && c == f, 87 + (Self::Snoozed { .. }, Self::Snoozed { .. }) => true, 88 + (Self::ManualPause { until: a }, Self::ManualPause { until: b }) => a == b, 89 + _ => false, 90 + } 91 + } 92 + } 93 + 59 94 pub struct TimerTask { 60 95 scheduler: Scheduler, 61 96 state: TimerState, ··· 67 102 pub fn spawn( 68 103 profile: Profile, 69 104 cfg: &AppConfig, 70 - ) -> (mpsc::UnboundedSender<TimerCommand>, std_mpsc::Receiver<TimerEvent>) { 105 + ) -> ( 106 + mpsc::UnboundedSender<TimerCommand>, 107 + std_mpsc::Receiver<TimerEvent>, 108 + ) { 71 109 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); 72 110 let (event_tx, event_rx) = std_mpsc::channel(); 73 111 let _ = cfg; ··· 120 158 TimerState::Snoozed { until } => { 121 159 if now >= *until { 122 160 let sched = self.current_break_or_next(); 161 + let fired_index = sched.level_index; 123 162 let is_long = sched.is_long_break; 124 - self.state = TimerState::Breaking { snooze_used: true, is_long }; 163 + self.state = TimerState::Breaking { 164 + snooze_used: true, 165 + is_long, 166 + fired_index, 167 + }; 125 168 let _ = self.event_tx.send(TimerEvent::BreakStarting(sched)); 126 169 } else { 127 170 let remaining = (*until).duration_since(now).as_secs(); 128 - let _ = self.event_tx.send(TimerEvent::SnoozePeriodStarting { remaining_secs: remaining }); 171 + let _ = self.event_tx.send(TimerEvent::SnoozePeriodStarting { 172 + remaining_secs: remaining, 173 + }); 129 174 } 130 175 } 131 176 TimerState::Breaking { .. } => { ··· 133 178 } 134 179 TimerState::Working => { 135 180 if let Some(break_due) = self.scheduler.tick(delta) { 181 + let fired_index = break_due.level_index; 136 182 let is_long = break_due.is_long_break; 137 - self.state = TimerState::Breaking { snooze_used: false, is_long }; 183 + self.state = TimerState::Breaking { 184 + snooze_used: false, 185 + is_long, 186 + fired_index, 187 + }; 138 188 let _ = self.event_tx.send(TimerEvent::BreakStarting(break_due)); 139 189 } else { 140 190 let secs = self.scheduler.secs_until_next_break(); 141 - let _ = self.event_tx.send(TimerEvent::WorkTick { secs_until_break: secs }); 191 + let _ = self.event_tx.send(TimerEvent::WorkTick { 192 + secs_until_break: secs, 193 + }); 142 194 } 143 195 } 144 196 } ··· 157 209 } 158 210 TimerCommand::SkipToNextBreak => { 159 211 if let Some(b) = self.scheduler.skip_to_next_break() { 212 + let fired_index = b.level_index; 160 213 let is_long = b.is_long_break; 161 - self.state = TimerState::Breaking { snooze_used: false, is_long }; 214 + self.state = TimerState::Breaking { 215 + snooze_used: false, 216 + is_long, 217 + fired_index, 218 + }; 162 219 let _ = self.event_tx.send(TimerEvent::BreakStarting(b)); 163 220 } 164 221 } ··· 171 228 remaining_secs: snooze_dur.as_secs(), 172 229 }); 173 230 } else { 174 - let was_long = matches!(&self.state, TimerState::Breaking { is_long: true, .. }); 175 - self.scheduler.record_break_completed(was_long); 231 + let (was_long, fired_index) = match self.state { 232 + TimerState::Breaking { 233 + is_long, 234 + fired_index, 235 + .. 236 + } => (is_long, fired_index), 237 + _ => (false, 0), 238 + }; 239 + self.scheduler.record_break_completed(fired_index, was_long); 176 240 self.state = TimerState::Working; 177 241 let _ = self.event_tx.send(TimerEvent::Resumed); 178 242 } 179 243 } 180 244 TimerCommand::IdleDetected { duration } => { 181 - if self.state == TimerState::Working { 245 + if matches!(self.state, TimerState::Working) { 182 246 self.scheduler.record_idle_break(duration); 183 247 self.state = TimerState::IdlePaused; 184 248 let _ = self.event_tx.send(TimerEvent::Paused); ··· 200 264 break_duration: self.scheduler.profile().primary_level().break_duration, 201 265 label: self.scheduler.profile().primary_level().label.clone(), 202 266 is_long_break: false, 267 + level_index: self.scheduler.profile().levels.len().saturating_sub(1), 203 268 }) 204 269 } 205 270 }
+160 -58
src/timer/scheduler.rs
··· 7 7 pub break_duration: Duration, 8 8 pub label: String, 9 9 pub is_long_break: bool, 10 + /// Which level index fired (passed back to record_break_completed). 11 + pub level_index: usize, 10 12 } 11 13 12 14 /// Tracks elapsed work time and cycle counts to determine the next break. 15 + /// 16 + /// Each break level has its own independent elapsed counter so that a 17 + /// short micro-break firing does not reset progress toward longer levels. 18 + /// For example, with levels [1 min micro, 3 min regular]: 19 + /// - level_elapsed[0] resets every time the micro fires 20 + /// - level_elapsed[1] keeps accumulating and fires independently at 3 min 13 21 pub struct Scheduler { 14 22 profile: Profile, 15 - /// Elapsed work time since the last break (or session start). 16 - work_elapsed: Duration, 23 + /// Per-level elapsed work time. Index matches profile.levels. 24 + level_elapsed: Vec<Duration>, 17 25 /// How many primary-level (longest interval) cycles have completed. 18 26 primary_cycle_count: u32, 19 27 /// When the last break ended (for cycle gap detection). ··· 22 30 23 31 impl Scheduler { 24 32 pub fn new(profile: Profile) -> Self { 33 + let n = profile.levels.len(); 25 34 Self { 35 + level_elapsed: vec![Duration::ZERO; n], 26 36 profile, 27 - work_elapsed: Duration::ZERO, 28 37 primary_cycle_count: 0, 29 38 last_break_ended: None, 30 39 } ··· 35 44 } 36 45 37 46 pub fn replace_profile(&mut self, profile: Profile) { 47 + let n = profile.levels.len(); 38 48 self.profile = profile; 39 - self.work_elapsed = Duration::ZERO; 49 + self.level_elapsed = vec![Duration::ZERO; n]; 40 50 self.primary_cycle_count = 0; 41 51 } 42 52 43 - /// Advance the work timer by `delta`. Returns a break to fire if one is due. 53 + /// Advance all level timers by `delta`. Returns a break to fire if one is due. 44 54 pub fn tick(&mut self, delta: Duration) -> Option<ScheduledBreak> { 45 - self.work_elapsed += delta; 55 + for elapsed in &mut self.level_elapsed { 56 + *elapsed += delta; 57 + } 46 58 self.due_break() 47 59 } 48 60 49 61 /// Force the next scheduled break to fire immediately (tray "skip to next break"). 50 62 pub fn skip_to_next_break(&mut self) -> Option<ScheduledBreak> { 51 - // Find the soonest upcoming break level and snap elapsed time to it. 52 - let next = self.next_break_level_index(); 53 - if let Some(idx) = next { 54 - self.work_elapsed = self.profile.levels[idx].work_duration; 63 + if let Some(idx) = self.next_break_level_index() { 64 + self.level_elapsed[idx] = self.profile.levels[idx].work_duration; 55 65 } else if let Some(lb) = &self.profile.long_break { 56 - // All regular levels done; snap to long break threshold. 66 + let primary_idx = self.profile.levels.len() - 1; 67 + let cycles_needed = lb.after_cycles.saturating_sub(self.primary_cycle_count); 57 68 let primary_work = self.profile.primary_level().work_duration; 58 - let cycles_needed = lb.after_cycles.saturating_sub(self.primary_cycle_count); 59 - self.work_elapsed = primary_work * cycles_needed; 69 + self.level_elapsed[primary_idx] = primary_work * cycles_needed; 60 70 } 61 71 self.due_break() 62 72 } 63 73 64 - /// Called when a break finishes. Updates cycle counts and resets elapsed work. 65 - pub fn record_break_completed(&mut self, was_long: bool) { 74 + /// Called when a break finishes. Resets only the fired level's counter; 75 + /// all other levels keep their accumulated time. 76 + pub fn record_break_completed(&mut self, fired_index: usize, was_long: bool) { 66 77 let now = Instant::now(); 67 78 68 - if !was_long { 69 - // Count primary-level cycle completions. 70 - let primary_dur = self.profile.primary_level().work_duration; 71 - if self.work_elapsed >= primary_dur { 72 - // Check if the gap since last break is within the long-break window. 73 - let gap_ok = self.last_break_ended.map_or(true, |t| { 74 - self.profile 75 - .long_break 76 - .as_ref() 77 - .map_or(true, |lb| now.duration_since(t) <= lb.max_cycle_gap) 78 - }); 79 + if was_long { 80 + log::debug!("scheduler: long break completed — resetting all counters"); 81 + self.primary_cycle_count = 0; 82 + for e in &mut self.level_elapsed { 83 + *e = Duration::ZERO; 84 + } 85 + self.last_break_ended = Some(now); 86 + return; 87 + } 88 + 89 + let primary_idx = self.profile.levels.len().saturating_sub(1); 79 90 80 - if gap_ok { 81 - self.primary_cycle_count += 1; 82 - } else { 83 - self.primary_cycle_count = 1; // reset, this is the first valid cycle 84 - } 91 + if fired_index == primary_idx { 92 + let gap_ok = self.last_break_ended.map_or(true, |t| { 93 + self.profile 94 + .long_break 95 + .as_ref() 96 + .map_or(true, |lb| now.duration_since(t) <= lb.max_cycle_gap) 97 + }); 98 + if gap_ok { 99 + self.primary_cycle_count += 1; 100 + } else { 101 + self.primary_cycle_count = 1; 85 102 } 103 + log::debug!( 104 + "scheduler: primary break completed (level {}), primary_cycles={}", 105 + fired_index, 106 + self.primary_cycle_count 107 + ); 86 108 } else { 87 - self.primary_cycle_count = 0; 109 + log::debug!( 110 + "scheduler: micro break completed (level {}), resetting only that level's counter", 111 + fired_index 112 + ); 88 113 } 89 114 90 - self.work_elapsed = Duration::ZERO; 115 + // Reset only the fired level; all others keep accumulating. 116 + self.level_elapsed[fired_index] = Duration::ZERO; 91 117 self.last_break_ended = Some(now); 118 + 119 + log::debug!( 120 + "scheduler: after record_break_completed — level_elapsed={:?}s, primary_cycles={}", 121 + self.level_elapsed 122 + .iter() 123 + .map(|d| d.as_secs()) 124 + .collect::<Vec<_>>(), 125 + self.primary_cycle_count 126 + ); 92 127 } 93 128 94 129 /// Called when idle is detected (natural break). Optionally resets cycle gap. ··· 98 133 self.primary_cycle_count = 0; 99 134 } 100 135 } 101 - self.work_elapsed = Duration::ZERO; 136 + for e in &mut self.level_elapsed { 137 + *e = Duration::ZERO; 138 + } 102 139 self.last_break_ended = Some(Instant::now()); 103 140 } 104 141 105 142 /// Seconds until the next break fires (for tray label). 106 143 pub fn secs_until_next_break(&self) -> u64 { 144 + if let Some(lb) = &self.profile.long_break { 145 + if self.primary_cycle_count >= lb.after_cycles { 146 + let primary_idx = self.profile.levels.len() - 1; 147 + let primary_dur = self.profile.primary_level().work_duration; 148 + return primary_dur 149 + .saturating_sub(self.level_elapsed[primary_idx]) 150 + .as_secs(); 151 + } 152 + } 107 153 if let Some(idx) = self.next_break_level_index() { 108 154 let due = self.profile.levels[idx].work_duration; 109 - due.saturating_sub(self.work_elapsed).as_secs() 110 - } else if let Some(lb) = &self.profile.long_break { 111 - let primary_dur = self.profile.primary_level().work_duration; 112 - let cycles_remaining = lb.after_cycles.saturating_sub(self.primary_cycle_count); 113 - let work_needed = primary_dur * cycles_remaining; 114 - work_needed.saturating_sub(self.work_elapsed).as_secs() 115 - } else { 116 - 0 155 + return due.saturating_sub(self.level_elapsed[idx]).as_secs(); 117 156 } 157 + 0 118 158 } 119 159 120 160 // --- Private helpers --- 121 161 122 - fn due_break(&mut self) -> Option<ScheduledBreak> { 123 - // Check long break first (highest priority when all cycles are done). 162 + fn due_break(&self) -> Option<ScheduledBreak> { 163 + // Long break takes priority when enough primary cycles have accumulated. 124 164 if let Some(lb) = &self.profile.long_break { 165 + let primary_idx = self.profile.levels.len() - 1; 125 166 let primary_dur = self.profile.primary_level().work_duration; 126 167 if self.primary_cycle_count >= lb.after_cycles 127 - && self.work_elapsed >= primary_dur 168 + && self.level_elapsed[primary_idx] >= primary_dur 128 169 { 170 + log::debug!( 171 + "scheduler: long break due (cycles={}/{})", 172 + self.primary_cycle_count, 173 + lb.after_cycles 174 + ); 129 175 return Some(ScheduledBreak { 130 176 break_duration: lb.break_duration, 131 177 label: lb.label.clone(), 132 178 is_long_break: true, 179 + level_index: primary_idx, 133 180 }); 134 181 } 135 182 } 136 183 137 - // Find the highest-priority (longest interval) level that is due. 138 - for (_idx, level) in self.profile.levels.iter().enumerate().rev() { 139 - if self.work_elapsed >= level.work_duration { 184 + // Highest-priority (longest work interval) level that is due. 185 + for (idx, level) in self.profile.levels.iter().enumerate().rev() { 186 + if self.level_elapsed[idx] >= level.work_duration { 187 + log::debug!( 188 + "scheduler: break due at level {} ({:?}), level_elapsed={}s", 189 + idx, 190 + level.label, 191 + self.level_elapsed[idx].as_secs() 192 + ); 140 193 return Some(ScheduledBreak { 141 194 break_duration: level.break_duration, 142 195 label: level.label.clone(), 143 196 is_long_break: false, 197 + level_index: idx, 144 198 }); 145 199 } 146 200 } 147 201 None 148 202 } 149 203 150 - /// Index of the soonest upcoming break level (the one with the smallest remaining time). 151 204 fn next_break_level_index(&self) -> Option<usize> { 152 205 self.profile 153 206 .levels 154 207 .iter() 155 208 .enumerate() 156 - .filter(|(_, l): &(usize, &BreakLevel)| self.work_elapsed < l.work_duration) 157 - .min_by_key(|(_, l)| l.work_duration.saturating_sub(self.work_elapsed)) 209 + .filter(|(i, l)| self.level_elapsed[*i] < l.work_duration) 210 + .min_by_key(|(i, l)| l.work_duration.saturating_sub(self.level_elapsed[*i])) 158 211 .map(|(i, _)| i) 159 212 } 160 213 } ··· 163 216 impl Scheduler { 164 217 pub fn primary_cycle_count(&self) -> u32 { 165 218 self.primary_cycle_count 219 + } 220 + 221 + pub fn level_elapsed_secs(&self, idx: usize) -> u64 { 222 + self.level_elapsed[idx].as_secs() 166 223 } 167 224 } 168 225 ··· 195 252 } 196 253 } 197 254 255 + fn tick_and_complete(s: &mut Scheduler, secs: u64) -> ScheduledBreak { 256 + let b = s 257 + .tick(Duration::from_secs(secs)) 258 + .expect("expected a break to fire"); 259 + s.record_break_completed(b.level_index, b.is_long_break); 260 + b 261 + } 262 + 198 263 #[test] 199 264 fn single_level_fires_at_interval() { 200 265 let mut s = Scheduler::new(make_profile(vec![(600, 300)], None)); ··· 206 271 207 272 #[test] 208 273 fn hierarchical_higher_priority_wins_at_alignment() { 209 - // levels: 10 min micro, 60 min regular 210 274 let mut s = Scheduler::new(make_profile(vec![(600, 15), (3600, 600)], None)); 211 - // At 60 min, both fire — top level (3600) should win 212 275 let b = s.tick(Duration::from_secs(3600)).unwrap(); 213 276 assert_eq!(b.break_duration, Duration::from_secs(600)); 214 277 } ··· 224 287 fn long_break_fires_after_n_cycles() { 225 288 let mut s = Scheduler::new(make_profile(vec![(600, 60)], Some((3, 1800, 1800)))); 226 289 for _ in 0..3 { 227 - s.tick(Duration::from_secs(600)); 228 - s.record_break_completed(false); 290 + tick_and_complete(&mut s, 600); 229 291 } 230 292 let b = s.tick(Duration::from_secs(600)).unwrap(); 231 293 assert!(b.is_long_break); ··· 235 297 #[test] 236 298 fn long_break_cycle_resets_on_large_gap() { 237 299 let mut s = Scheduler::new(make_profile(vec![(600, 60)], Some((3, 1800, 1800)))); 238 - s.tick(Duration::from_secs(600)); 239 - s.record_break_completed(false); 300 + tick_and_complete(&mut s, 600); 240 301 assert_eq!(s.primary_cycle_count(), 1); 241 - // Simulate idle longer than max_cycle_gap 242 302 s.record_idle_break(Duration::from_secs(1801)); 243 303 assert_eq!(s.primary_cycle_count(), 0); 244 304 } ··· 248 308 let mut s = Scheduler::new(make_profile(vec![(600, 60)], None)); 249 309 s.tick(Duration::from_secs(100)); 250 310 assert_eq!(s.secs_until_next_break(), 500); 311 + } 312 + 313 + #[test] 314 + fn primary_level_independent_of_micro() { 315 + let mut s = Scheduler::new(make_profile(vec![(60, 15), (180, 60)], None)); 316 + 317 + // t=60s: micro fires; primary counter stays at 60s 318 + let b1 = tick_and_complete(&mut s, 60); 319 + assert_eq!(b1.break_duration, Duration::from_secs(15)); 320 + assert_eq!( 321 + s.level_elapsed_secs(1), 322 + 60, 323 + "primary must still be at 60s after micro" 324 + ); 325 + 326 + // t=120s: micro fires again; primary at 120s 327 + let b2 = tick_and_complete(&mut s, 60); 328 + assert_eq!(b2.break_duration, Duration::from_secs(15)); 329 + assert_eq!(s.level_elapsed_secs(1), 120, "primary must be at 120s"); 330 + 331 + // t=180s: primary fires (both due; longest wins) 332 + let b3 = s.tick(Duration::from_secs(60)).unwrap(); 333 + assert_eq!( 334 + b3.break_duration, 335 + Duration::from_secs(60), 336 + "3-min primary should fire" 337 + ); 338 + } 339 + 340 + #[test] 341 + fn long_break_fires_with_hierarchical_levels() { 342 + let mut s = Scheduler::new(make_profile(vec![(60, 15), (180, 60)], Some((3, 600, 600)))); 343 + // 3 primary cycles: each cycle has 2 micros then the primary 344 + for _ in 0..3 { 345 + tick_and_complete(&mut s, 60); // micro at 60s 346 + tick_and_complete(&mut s, 60); // micro at 120s 347 + tick_and_complete(&mut s, 60); // primary at 180s 348 + } 349 + assert_eq!(s.primary_cycle_count(), 3); 350 + let b = s.tick(Duration::from_secs(180)).unwrap(); 351 + assert!(b.is_long_break); 352 + assert_eq!(b.break_duration, Duration::from_secs(600)); 251 353 } 252 354 }