1use std::time::{Duration, Instant};
2
3use chrono::{Local, Timelike};
4
5use super::types::BreakKind;
6
7#[derive(Debug)]
13pub struct BreakTimers {
14 pub last_micro: Instant,
15 pub last_long: Instant,
16 pub last_sleep: Option<Instant>,
17 pub micro_warned: bool,
18 pub long_warned: bool,
19 pub active_break: Option<BreakKind>,
20 pub micro_deferred_since: Option<Instant>,
21 pub long_deferred_since: Option<Instant>,
22 pub micro_postpone_count: u32,
23 pub long_postpone_count: u32,
24 pub last_skipped_or_postponed: Option<(BreakKind, Instant)>,
25 pub last_micro_fixed_fire: Option<(String, u32)>,
31 pub last_long_fixed_fire: Option<(String, u32)>,
32}
33
34impl BreakTimers {
35 pub fn new() -> Self {
38 let now = Instant::now();
39 Self {
40 last_micro: now,
41 last_long: now,
42 last_sleep: None,
43 micro_warned: false,
44 long_warned: false,
45 active_break: None,
46 micro_deferred_since: None,
47 long_deferred_since: None,
48 micro_postpone_count: 0,
49 long_postpone_count: 0,
50 last_skipped_or_postponed: None,
51 last_micro_fixed_fire: None,
52 last_long_fixed_fire: None,
53 }
54 }
55}
56
57pub fn reset_timers_keep_sleep(t: &mut BreakTimers) {
62 let now = Instant::now();
63 t.last_micro = now;
64 t.last_long = now;
65 t.micro_warned = false;
66 t.long_warned = false;
67 t.micro_deferred_since = None;
68 t.long_deferred_since = None;
69 t.micro_postpone_count = 0;
70 t.long_postpone_count = 0;
71}
72
73pub fn clear_last_break(t: &mut BreakTimers) -> bool {
77 if t.last_skipped_or_postponed.is_some() {
78 t.last_skipped_or_postponed = None;
79 true
80 } else {
81 false
82 }
83}
84
85pub fn current_minutes() -> u32 {
89 let now = Local::now();
90 now.hour() * 60 + now.minute()
91}
92
93pub fn local_today_string() -> String {
96 Local::now().date_naive().format("%Y-%m-%d").to_string()
97}
98
99pub fn parse_hhmm(s: &str) -> Option<u32> {
103 let trimmed = s.trim();
104 let (h_str, m_str) = trimmed.split_once(':')?;
105 if h_str.is_empty() || m_str.len() != 2 {
106 return None;
107 }
108 let h: u32 = h_str.parse().ok()?;
109 let m: u32 = m_str.parse().ok()?;
110 if h >= 24 || m >= 60 {
111 return None;
112 }
113 Some(h * 60 + m)
114}
115
116pub fn should_fire_fixed_now(
123 today: &str,
124 current_min: u32,
125 last_fire: Option<&(String, u32)>,
126) -> bool {
127 match last_fire {
128 Some((day, prev_min)) => day != today || *prev_min != current_min,
129 None => true,
130 }
131}
132
133pub fn in_window(now: u32, start: u32, end: u32) -> bool {
137 if start == end {
138 return false;
139 }
140 if start < end {
141 now >= start && now < end
142 } else {
143 now >= start || now < end
144 }
145}
146
147pub fn interval_break_due(
157 enabled: bool,
158 mode_includes_interval: bool,
159 last_fire: Instant,
160 interval_secs: u64,
161 idle_suppressed: bool,
162 now: Instant,
163) -> bool {
164 enabled
165 && mode_includes_interval
166 && !idle_suppressed
167 && now.saturating_duration_since(last_fire) >= Duration::from_secs(interval_secs)
168}
169
170#[allow(clippy::too_many_arguments)]
179pub fn prebreak_warn_due(
180 enabled: bool,
181 mode_includes_interval: bool,
182 last_fire: Instant,
183 interval_secs: u64,
184 lead_secs: u64,
185 already_warned: bool,
186 idle_suppressed: bool,
187 now: Instant,
188) -> bool {
189 if !enabled || !mode_includes_interval || idle_suppressed || already_warned {
190 return false;
191 }
192 let interval = Duration::from_secs(interval_secs);
193 let lead = Duration::from_secs(lead_secs);
194 let warn_at = interval.saturating_sub(lead);
195 let elapsed = now.saturating_duration_since(last_fire);
196 elapsed >= warn_at && elapsed < interval
197}
198
199#[derive(Debug, PartialEq, Eq, Clone, Copy)]
203pub enum BedtimeAction {
204 Fire,
206 ResetTimersOnly,
210 NotInWindow,
212}
213
214pub fn decide_bedtime(
220 enabled: bool,
221 now_min: u32,
222 start_min: u32,
223 end_min: u32,
224 interval_secs: u64,
225 last_sleep_fire: Option<Instant>,
226 now: Instant,
227) -> BedtimeAction {
228 if !enabled || !in_window(now_min, start_min, end_min) {
229 return BedtimeAction::NotInWindow;
230 }
231 let should_fire = match last_sleep_fire {
232 None => true,
233 Some(t) => now.saturating_duration_since(t) >= Duration::from_secs(interval_secs),
234 };
235 if should_fire {
236 BedtimeAction::Fire
237 } else {
238 BedtimeAction::ResetTimersOnly
239 }
240}
241
242pub fn should_defer_for_typing(
250 enabled: bool,
251 idle_secs: u64,
252 grace_secs: u64,
253 deferred_since: Option<Instant>,
254 max_deferral_secs: u64,
255 now: Instant,
256) -> bool {
257 if !enabled || grace_secs == 0 {
258 return false;
259 }
260 if idle_secs >= grace_secs {
261 return false;
262 }
263 match deferred_since {
264 None => true,
265 Some(started) => now.duration_since(started) < Duration::from_secs(max_deferral_secs),
266 }
267}
268
269pub fn postpone_counter(t: &BreakTimers, kind: BreakKind) -> u32 {
272 match kind {
273 BreakKind::Micro => t.micro_postpone_count,
274 BreakKind::Long => t.long_postpone_count,
275 BreakKind::Sleep => 0,
276 }
277}
278
279pub fn reset_postpone_counter(t: &mut BreakTimers, kind: BreakKind) {
282 match kind {
283 BreakKind::Micro => t.micro_postpone_count = 0,
284 BreakKind::Long => t.long_postpone_count = 0,
285 BreakKind::Sleep => {}
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::time::Duration;
293
294 #[test]
295 fn in_window_normal() {
296 assert!(in_window(540, 540, 1080));
297 assert!(in_window(800, 540, 1080));
298 assert!(in_window(1079, 540, 1080));
299 assert!(!in_window(539, 540, 1080));
300 assert!(!in_window(1080, 540, 1080));
301 assert!(!in_window(0, 540, 1080));
302 }
303
304 #[test]
305 fn in_window_wraps_midnight() {
306 assert!(in_window(1320, 1320, 360));
307 assert!(in_window(1439, 1320, 360));
308 assert!(in_window(0, 1320, 360));
309 assert!(in_window(359, 1320, 360));
310 assert!(!in_window(360, 1320, 360));
311 assert!(!in_window(720, 1320, 360));
312 }
313
314 #[test]
315 fn in_window_empty_when_equal() {
316 assert!(!in_window(0, 720, 720));
317 assert!(!in_window(720, 720, 720));
318 assert!(!in_window(1000, 720, 720));
319 }
320
321 #[test]
322 fn current_minutes_in_range() {
323 let m = current_minutes();
324 assert!(m < 24 * 60);
325 }
326
327 #[test]
328 fn parse_hhmm_valid_two_digit_hour() {
329 assert_eq!(parse_hhmm("00:00"), Some(0));
330 assert_eq!(parse_hhmm("09:15"), Some(555));
331 assert_eq!(parse_hhmm("12:30"), Some(12 * 60 + 30));
332 assert_eq!(parse_hhmm("23:59"), Some(23 * 60 + 59));
333 }
334
335 #[test]
336 fn parse_hhmm_single_digit_hour() {
337 assert_eq!(parse_hhmm("8:05"), Some(8 * 60 + 5));
338 assert_eq!(parse_hhmm("9:00"), Some(9 * 60));
339 }
340
341 #[test]
342 fn parse_hhmm_trims_whitespace() {
343 assert_eq!(parse_hhmm(" 12:30 "), Some(12 * 60 + 30));
344 }
345
346 #[test]
347 fn parse_hhmm_rejects_out_of_range() {
348 assert_eq!(parse_hhmm("24:00"), None);
349 assert_eq!(parse_hhmm("99:99"), None);
350 assert_eq!(parse_hhmm("12:60"), None);
351 assert_eq!(parse_hhmm("25:30"), None);
352 }
353
354 #[test]
355 fn parse_hhmm_rejects_garbage() {
356 assert_eq!(parse_hhmm(""), None);
357 assert_eq!(parse_hhmm("abc"), None);
358 assert_eq!(parse_hhmm("12:3"), None);
359 assert_eq!(parse_hhmm(":30"), None);
360 assert_eq!(parse_hhmm("12:"), None);
361 assert_eq!(parse_hhmm("12-30"), None);
362 assert_eq!(parse_hhmm("12:30:00"), None);
363 }
364
365 #[test]
366 fn should_fire_fixed_now_first_fire() {
367 assert!(should_fire_fixed_now("2026-03-08", 750, None));
368 }
369
370 #[test]
371 fn should_fire_fixed_now_same_day_same_minute_dedupes() {
372 let last = ("2026-03-08".to_string(), 750u32);
373 assert!(!should_fire_fixed_now("2026-03-08", 750, Some(&last)));
374 }
375
376 #[test]
377 fn should_fire_fixed_now_same_day_different_minute_refires() {
378 let last = ("2026-03-08".to_string(), 750u32);
379 assert!(should_fire_fixed_now("2026-03-08", 751, Some(&last)));
380 assert!(should_fire_fixed_now("2026-03-08", 1020, Some(&last)));
381 }
382
383 #[test]
384 fn should_fire_fixed_now_new_day_refires_same_minute() {
385 let last = ("2026-03-08".to_string(), 750u32);
388 assert!(should_fire_fixed_now("2026-03-09", 750, Some(&last)));
389 }
390
391 #[test]
392 fn should_fire_fixed_now_dst_fall_back_does_not_double_fire() {
393 let last = ("2026-11-01".to_string(), 90u32); assert!(!should_fire_fixed_now("2026-11-01", 90, Some(&last)));
400 }
401
402 #[test]
403 fn should_fire_fixed_now_dst_spring_forward_does_not_resurrect_skipped_minute() {
404 let last = ("2026-03-08".to_string(), 150u32); assert!(should_fire_fixed_now("2026-03-09", 180, Some(&last)));
412 }
413
414 #[test]
415 fn fixed_dedupe_state_clears_on_break_timers_new() {
416 let t = BreakTimers::new();
417 assert!(t.last_micro_fixed_fire.is_none());
418 assert!(t.last_long_fixed_fire.is_none());
419 }
420
421 #[test]
422 fn typing_defer_disabled_returns_false() {
423 let now = Instant::now();
424 assert!(!should_defer_for_typing(false, 0, 10, None, 60, now));
425 }
426
427 #[test]
428 fn typing_defer_zero_grace_returns_false() {
429 let now = Instant::now();
430 assert!(!should_defer_for_typing(true, 0, 0, None, 60, now));
431 }
432
433 #[test]
434 fn typing_defer_when_actively_typing_first_tick() {
435 let now = Instant::now();
436 assert!(should_defer_for_typing(true, 1, 10, None, 60, now));
437 }
438
439 #[test]
440 fn typing_defer_idle_above_grace_does_not_defer() {
441 let now = Instant::now();
442 assert!(!should_defer_for_typing(true, 10, 10, None, 60, now));
443 assert!(!should_defer_for_typing(true, 30, 10, None, 60, now));
444 }
445
446 #[test]
447 fn typing_defer_within_cap_keeps_deferring() {
448 let started = Instant::now();
452 let now = started + Duration::from_secs(30);
453 assert!(should_defer_for_typing(true, 1, 10, Some(started), 60, now));
454 }
455
456 #[test]
457 fn typing_defer_cap_reached_fires_anyway() {
458 let started = Instant::now();
459 let now = started + Duration::from_secs(60);
460 assert!(!should_defer_for_typing(
461 true,
462 1,
463 10,
464 Some(started),
465 60,
466 now
467 ));
468 let older = Instant::now();
469 let now_later = older + Duration::from_secs(120);
470 assert!(!should_defer_for_typing(
471 true,
472 1,
473 10,
474 Some(older),
475 60,
476 now_later
477 ));
478 }
479
480 #[test]
481 fn postpone_counter_reads_per_kind() {
482 let mut t = BreakTimers::new();
483 t.micro_postpone_count = 2;
484 t.long_postpone_count = 5;
485 assert_eq!(postpone_counter(&t, BreakKind::Micro), 2);
486 assert_eq!(postpone_counter(&t, BreakKind::Long), 5);
487 assert_eq!(postpone_counter(&t, BreakKind::Sleep), 0);
488 }
489
490 #[test]
491 fn reset_postpone_counter_only_clears_target_kind() {
492 let mut t = BreakTimers::new();
493 t.micro_postpone_count = 3;
494 t.long_postpone_count = 4;
495 reset_postpone_counter(&mut t, BreakKind::Micro);
496 assert_eq!(t.micro_postpone_count, 0);
497 assert_eq!(t.long_postpone_count, 4);
498 reset_postpone_counter(&mut t, BreakKind::Long);
499 assert_eq!(t.long_postpone_count, 0);
500 }
501
502 #[test]
503 fn clear_last_break_returns_whether_cleared() {
504 let mut t = BreakTimers::new();
505 assert!(!clear_last_break(&mut t));
506 t.last_skipped_or_postponed = Some((BreakKind::Long, Instant::now()));
507 assert!(clear_last_break(&mut t));
508 assert!(t.last_skipped_or_postponed.is_none());
509 assert!(!clear_last_break(&mut t));
510 }
511
512 #[test]
513 fn reset_timers_keep_sleep_preserves_last_sleep_and_active_break() {
514 let mut t = BreakTimers::new();
515 let sleep_at = Instant::now();
516 t.last_sleep = Some(sleep_at);
517 t.active_break = Some(BreakKind::Long);
518 t.micro_warned = true;
519 t.long_warned = true;
520 t.micro_postpone_count = 2;
521 t.long_postpone_count = 3;
522 t.micro_deferred_since = Some(Instant::now());
523 t.long_deferred_since = Some(Instant::now());
524
525 reset_timers_keep_sleep(&mut t);
526
527 assert_eq!(t.last_sleep, Some(sleep_at));
528 assert_eq!(t.active_break, Some(BreakKind::Long));
529 assert!(!t.micro_warned);
530 assert!(!t.long_warned);
531 assert_eq!(t.micro_postpone_count, 0);
532 assert_eq!(t.long_postpone_count, 0);
533 assert!(t.micro_deferred_since.is_none());
534 assert!(t.long_deferred_since.is_none());
535 }
536
537 #[test]
538 fn reset_timers_keep_sleep_clears_with_no_sleep() {
539 let mut t = BreakTimers::new();
540 assert!(t.last_sleep.is_none());
541 t.micro_warned = true;
542 reset_timers_keep_sleep(&mut t);
543 assert!(t.last_sleep.is_none());
544 assert!(!t.micro_warned);
545 }
546
547 #[test]
555 fn interval_due_fires_when_interval_elapsed() {
556 let last = Instant::now();
557 let now = last + Duration::from_secs(1200);
558 assert!(interval_break_due(true, true, last, 1200, false, now));
559 }
560
561 #[test]
562 fn interval_due_does_not_fire_before_interval() {
563 let last = Instant::now();
564 let now = last + Duration::from_secs(1199);
565 assert!(!interval_break_due(true, true, last, 1200, false, now));
566 }
567
568 #[test]
569 fn interval_due_respects_enabled_flag() {
570 let last = Instant::now();
571 let now = last + Duration::from_secs(2000);
572 assert!(!interval_break_due(false, true, last, 1200, false, now));
573 }
574
575 #[test]
576 fn interval_due_respects_mode_flag() {
577 let last = Instant::now();
581 let now = last + Duration::from_secs(2000);
582 assert!(!interval_break_due(true, false, last, 1200, false, now));
583 }
584
585 #[test]
586 fn interval_due_respects_idle_suppression() {
587 let last = Instant::now();
588 let now = last + Duration::from_secs(2000);
589 assert!(!interval_break_due(true, true, last, 1200, true, now));
590 }
591
592 #[test]
593 fn interval_due_handles_clock_skew_safely() {
594 let now = Instant::now();
597 let future = now + Duration::from_secs(60);
598 assert!(!interval_break_due(true, true, future, 30, false, now));
599 }
600
601 #[test]
606 fn prebreak_warn_fires_inside_lead_window() {
607 let last = Instant::now();
609 let now = last + Duration::from_secs(1150);
610 assert!(prebreak_warn_due(
611 true, true, last, 1200, 60, false, false, now
612 ));
613 }
614
615 #[test]
616 fn prebreak_warn_does_not_fire_before_lead_window() {
617 let last = Instant::now();
619 let now = last + Duration::from_secs(1100);
620 assert!(!prebreak_warn_due(
621 true, true, last, 1200, 60, false, false, now
622 ));
623 }
624
625 #[test]
626 fn prebreak_warn_does_not_fire_after_break_due() {
627 let last = Instant::now();
630 let now = last + Duration::from_secs(1200);
631 assert!(!prebreak_warn_due(
632 true, true, last, 1200, 60, false, false, now
633 ));
634 let way_late = Instant::now();
635 let later_now = way_late + Duration::from_secs(1250);
636 assert!(!prebreak_warn_due(
637 true, true, way_late, 1200, 60, false, false, later_now
638 ));
639 }
640
641 #[test]
642 fn prebreak_warn_dedupes_via_already_warned() {
643 let last = Instant::now();
644 let now = last + Duration::from_secs(1150);
645 assert!(!prebreak_warn_due(
646 true, true, last, 1200, 60, true, false, now
647 ));
648 }
649
650 #[test]
651 fn prebreak_warn_skips_when_disabled_or_idle() {
652 let last = Instant::now();
653 let now = last + Duration::from_secs(1150);
654 assert!(!prebreak_warn_due(
655 false, true, last, 1200, 60, false, false, now
656 ));
657 assert!(!prebreak_warn_due(
658 true, false, last, 1200, 60, false, false, now
659 ));
660 assert!(!prebreak_warn_due(
661 true, true, last, 1200, 60, false, true, now
662 ));
663 }
664
665 #[test]
666 fn prebreak_warn_handles_lead_larger_than_interval() {
667 let last = Instant::now();
671 let now = last + Duration::from_secs(10);
672 assert!(prebreak_warn_due(
673 true, true, last, 60, 600, false, false, now
674 ));
675 }
676
677 #[test]
682 fn bedtime_not_in_window_returns_not_in_window() {
683 let now = Instant::now();
684 assert_eq!(
686 decide_bedtime(true, 12 * 60, 22 * 60, 6 * 60, 1800, None, now),
687 BedtimeAction::NotInWindow
688 );
689 }
690
691 #[test]
692 fn bedtime_disabled_returns_not_in_window_even_in_range() {
693 let now = Instant::now();
694 assert_eq!(
695 decide_bedtime(false, 23 * 60, 22 * 60, 6 * 60, 1800, None, now),
696 BedtimeAction::NotInWindow
697 );
698 }
699
700 #[test]
701 fn bedtime_first_tick_of_window_fires() {
702 let now = Instant::now();
703 assert_eq!(
704 decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, None, now),
705 BedtimeAction::Fire
706 );
707 }
708
709 #[test]
710 fn bedtime_re_fires_after_interval() {
711 let last = Instant::now();
712 let now = last + Duration::from_secs(1800);
713 assert_eq!(
714 decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(last), now),
715 BedtimeAction::Fire
716 );
717 }
718
719 #[test]
720 fn bedtime_resets_only_inside_interval() {
721 let last = Instant::now();
723 let now = last + Duration::from_secs(900);
724 assert_eq!(
725 decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(last), now),
726 BedtimeAction::ResetTimersOnly
727 );
728 }
729
730 #[test]
731 fn bedtime_window_handles_midnight_wrap() {
732 let now = Instant::now();
733 assert_eq!(
735 decide_bedtime(true, 2 * 60, 22 * 60, 6 * 60, 1800, None, now),
736 BedtimeAction::Fire
737 );
738 }
739
740 #[test]
741 fn bedtime_handles_clock_skew_safely() {
742 let now = Instant::now();
745 let future = now + Duration::from_secs(60);
746 assert_eq!(
747 decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(future), now),
748 BedtimeAction::ResetTimersOnly
749 );
750 }
751}