Skip to main content

entracte_lib/scheduler/
tray_countdown.rs

1use std::sync::atomic::Ordering;
2use std::time::Instant;
3
4use super::pause::PauseState;
5use super::timers::{current_minutes, in_window};
6use super::types::SuppressReason;
7use super::Scheduler;
8
9/// One-second snapshot of what the tray ticker should display.
10///
11/// Drives both the icon swap (Normal / Paused / Inactive / Bedtime) and
12/// the adjacent text. `Disabled` means the user has turned the text-
13/// countdown setting off; visual states (Paused / Bedtime / OnBreak /
14/// Suppressed) still take precedence so the icon stays accurate.
15///
16/// `Suppressed` carries the specific guard that's silencing breaks so
17/// the tray can spell out *why* in its title and tooltip — without
18/// that, the inactive icon looked identical to a user-initiated pause.
19#[cfg_attr(target_os = "windows", allow(dead_code))]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TrayCountdownSnapshot {
22    Disabled,
23    Paused,
24    Bedtime,
25    OnBreak,
26    Suppressed(SuppressReason),
27    Idle,
28    Running(u64),
29}
30
31/// Format remaining seconds as `M:SS` (or `MM:SS` past ten minutes)
32/// for the tray title. macOS/Linux only; Windows trays don't render text.
33#[cfg_attr(target_os = "windows", allow(dead_code))]
34pub fn format_countdown(secs: u64) -> String {
35    let m = secs / 60;
36    let s = secs % 60;
37    if m >= 10 {
38        format!("{m:02}:{s:02}")
39    } else {
40        format!("{m}:{s:02}")
41    }
42}
43
44/// Select which countdown the tray should show according to the
45/// user's `tray_countdown_target` setting. `"short"` returns the micro
46/// timer, `"long"` returns the long timer, anything else returns
47/// whichever is sooner (the default `"next"` behaviour).
48#[cfg_attr(target_os = "windows", allow(dead_code))]
49pub fn pick_countdown_secs(target: &str, micro: Option<u64>, long: Option<u64>) -> Option<u64> {
50    match target {
51        "short" => micro,
52        "long" => long,
53        _ => match (micro, long) {
54            (Some(m), Some(l)) => Some(m.min(l)),
55            (Some(m), None) => Some(m),
56            (None, Some(l)) => Some(l),
57            (None, None) => None,
58        },
59    }
60}
61
62impl Scheduler {
63    /// Snapshot the per-tick state the tray ticker needs. Polled once
64    /// per second on macOS/Linux. See `TrayCountdownSnapshot` for the
65    /// precedence rules.
66    ///
67    /// Returns `(snapshot, text_enabled)`. `text_enabled` mirrors the
68    /// user's `tray_countdown_enabled` setting — the ticker uses it to
69    /// gate the always-visible title text (icon + tooltip aren't
70    /// gated, since the icon is the visual signal and the tooltip is
71    /// hover-only opt-in).
72    #[cfg_attr(target_os = "windows", allow(dead_code))]
73    pub async fn tray_countdown_snapshot(&self) -> (TrayCountdownSnapshot, bool) {
74        let s = self.settings.lock().await.clone();
75        let paused = !matches!(*self.pause_state.lock().await, PauseState::Running);
76        let bedtime_active = s.bedtime_enabled
77            && in_window(
78                current_minutes(),
79                s.bedtime_start_minutes,
80                s.bedtime_end_minutes,
81            );
82        let on_break = self
83            .current_break
84            .lock()
85            .ok()
86            .and_then(|s| s.clone())
87            .is_some();
88        let suppress_reason =
89            SuppressReason::from_u8(self.auto_suppress_reason.load(Ordering::Relaxed));
90
91        // Only compute the time-to-next-break when the result would be used;
92        // visual-mode snapshots (paused / bedtime / on-break / suppressed) win.
93        let countdown_secs = if paused || bedtime_active || on_break || suppress_reason.is_some() {
94            None
95        } else {
96            let t = self.timers.lock().await;
97            let now = Instant::now();
98            let micro_secs = if s.micro_enabled
99                && matches!(s.micro_schedule_mode.as_str(), "interval" | "both")
100            {
101                let elapsed = now.saturating_duration_since(t.last_micro).as_secs();
102                Some(s.micro_interval_secs.saturating_sub(elapsed))
103            } else {
104                None
105            };
106            let long_secs =
107                if s.long_enabled && matches!(s.long_schedule_mode.as_str(), "interval" | "both") {
108                    let elapsed = now.saturating_duration_since(t.last_long).as_secs();
109                    Some(s.long_interval_secs.saturating_sub(elapsed))
110                } else {
111                    None
112                };
113            pick_countdown_secs(&s.tray_countdown_target, micro_secs, long_secs)
114        };
115
116        let snapshot = decide_tray_snapshot(
117            s.tray_countdown_enabled,
118            paused,
119            bedtime_active,
120            on_break,
121            suppress_reason,
122            countdown_secs,
123        );
124        (snapshot, s.tray_countdown_enabled)
125    }
126}
127
128// Pure decision tree for the tray snapshot. Visual-mode signals (paused,
129// bedtime, on-break, auto-suppressed) take precedence over the text-countdown
130// gate, so the icon swaps even when `tray_countdown_enabled` is false. Only
131// the Idle / Running text states honour that flag.
132#[cfg_attr(target_os = "windows", allow(dead_code))]
133fn decide_tray_snapshot(
134    text_enabled: bool,
135    paused: bool,
136    bedtime_active: bool,
137    on_break: bool,
138    suppress_reason: Option<SuppressReason>,
139    countdown_secs: Option<u64>,
140) -> TrayCountdownSnapshot {
141    if paused {
142        return TrayCountdownSnapshot::Paused;
143    }
144    if bedtime_active {
145        return TrayCountdownSnapshot::Bedtime;
146    }
147    if on_break {
148        return TrayCountdownSnapshot::OnBreak;
149    }
150    if let Some(reason) = suppress_reason {
151        return TrayCountdownSnapshot::Suppressed(reason);
152    }
153    if !text_enabled {
154        return TrayCountdownSnapshot::Disabled;
155    }
156    match countdown_secs {
157        Some(s) => TrayCountdownSnapshot::Running(s),
158        None => TrayCountdownSnapshot::Idle,
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn decide_tray_snapshot_paused_wins_over_everything() {
168        // Pause is an explicit user action — it takes precedence even over
169        // bedtime / on-break, so the user always sees a clear "paused" signal.
170        assert_eq!(
171            decide_tray_snapshot(true, true, true, true, Some(SuppressReason::Dnd), Some(60)),
172            TrayCountdownSnapshot::Paused,
173        );
174        assert_eq!(
175            decide_tray_snapshot(false, true, false, false, None, None),
176            TrayCountdownSnapshot::Paused,
177        );
178    }
179
180    #[test]
181    fn decide_tray_snapshot_bedtime_ignores_text_countdown_gate() {
182        // Regression test for the bug where `tray_countdown_enabled=false`
183        // short-circuited the snapshot and the bedtime icon never swapped.
184        assert_eq!(
185            decide_tray_snapshot(false, false, true, false, None, None),
186            TrayCountdownSnapshot::Bedtime,
187        );
188        // Bedtime also outranks on-break + auto-suppressed.
189        assert_eq!(
190            decide_tray_snapshot(
191                true,
192                false,
193                true,
194                true,
195                Some(SuppressReason::Video),
196                Some(60)
197            ),
198            TrayCountdownSnapshot::Bedtime,
199        );
200    }
201
202    #[test]
203    fn decide_tray_snapshot_on_break_beats_suppressed_but_not_bedtime() {
204        assert_eq!(
205            decide_tray_snapshot(true, false, false, true, Some(SuppressReason::Camera), None),
206            TrayCountdownSnapshot::OnBreak,
207        );
208    }
209
210    #[test]
211    fn decide_tray_snapshot_suppressed_ignores_text_countdown_gate() {
212        // Same fix as bedtime — suppressed states drive the dim icon and must
213        // surface even when the countdown text is off. Carries the reason.
214        assert_eq!(
215            decide_tray_snapshot(false, false, false, false, Some(SuppressReason::Dnd), None),
216            TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd),
217        );
218    }
219
220    #[test]
221    fn decide_tray_snapshot_suppressed_carries_each_reason_through() {
222        for r in [
223            SuppressReason::WorkWindow,
224            SuppressReason::Dnd,
225            SuppressReason::Camera,
226            SuppressReason::Video,
227            SuppressReason::AppPause,
228        ] {
229            assert_eq!(
230                decide_tray_snapshot(true, false, false, false, Some(r), None),
231                TrayCountdownSnapshot::Suppressed(r),
232                "{r:?} should round-trip through decide_tray_snapshot",
233            );
234        }
235    }
236
237    #[test]
238    fn decide_tray_snapshot_disabled_only_when_no_visual_signal() {
239        // With text disabled and no visual signal active, return Disabled
240        // (renders the normal icon with no title text).
241        assert_eq!(
242            decide_tray_snapshot(false, false, false, false, None, Some(60)),
243            TrayCountdownSnapshot::Disabled,
244        );
245    }
246
247    #[test]
248    fn decide_tray_snapshot_running_when_text_enabled_and_idle_otherwise() {
249        assert_eq!(
250            decide_tray_snapshot(true, false, false, false, None, Some(125)),
251            TrayCountdownSnapshot::Running(125),
252        );
253        assert_eq!(
254            decide_tray_snapshot(true, false, false, false, None, None),
255            TrayCountdownSnapshot::Idle,
256        );
257    }
258
259    #[test]
260    fn pick_countdown_secs_target_short() {
261        assert_eq!(pick_countdown_secs("short", Some(60), Some(900)), Some(60));
262        assert_eq!(pick_countdown_secs("short", None, Some(900)), None);
263    }
264
265    #[test]
266    fn pick_countdown_secs_target_long() {
267        assert_eq!(pick_countdown_secs("long", Some(60), Some(900)), Some(900));
268        assert_eq!(pick_countdown_secs("long", Some(60), None), None);
269    }
270
271    #[test]
272    fn pick_countdown_secs_target_next_picks_min() {
273        assert_eq!(pick_countdown_secs("next", Some(60), Some(900)), Some(60));
274        assert_eq!(pick_countdown_secs("next", Some(900), Some(60)), Some(60));
275        assert_eq!(pick_countdown_secs("next", Some(120), None), Some(120));
276        assert_eq!(pick_countdown_secs("next", None, Some(120)), Some(120));
277        assert_eq!(pick_countdown_secs("next", None, None), None);
278        assert_eq!(pick_countdown_secs("garbage", Some(5), Some(9)), Some(5));
279    }
280
281    #[test]
282    fn format_countdown_under_ten_minutes() {
283        assert_eq!(format_countdown(0), "0:00");
284        assert_eq!(format_countdown(5), "0:05");
285        assert_eq!(format_countdown(59), "0:59");
286        assert_eq!(format_countdown(60), "1:00");
287        assert_eq!(format_countdown(125), "2:05");
288        assert_eq!(format_countdown(9 * 60 + 59), "9:59");
289    }
290
291    #[test]
292    fn format_countdown_ten_minutes_or_more() {
293        assert_eq!(format_countdown(10 * 60), "10:00");
294        assert_eq!(format_countdown(12 * 60 + 34), "12:34");
295        assert_eq!(format_countdown(59 * 60 + 59), "59:59");
296        assert_eq!(format_countdown(60 * 60), "60:00");
297    }
298
299    // ----- Scheduler::tray_countdown_snapshot integration -----
300
301    use crate::config::{Profile, DEFAULT_PROFILE_NAME};
302    use crate::scheduler::break_stats::BreakStats;
303    use crate::scheduler::screen_time::ScreenTimeState;
304    use crate::scheduler::settings::Settings;
305    use crate::scheduler::timers::BreakTimers;
306    use crate::scheduler::types::BreakEvent as InternalBreakEvent;
307    use crate::scheduler::types::BreakKind;
308    use crate::screen_time_store::ScreenTimeSnapshot;
309    use crate::stats::Logger;
310    use crate::test_support::{temp_dir, TempDir};
311    use std::sync::atomic::{AtomicBool, AtomicU8};
312    use std::sync::Arc;
313    use tokio::sync::Mutex as TokioMutex;
314
315    fn build_test_scheduler(settings: Settings) -> (TempDir, Scheduler) {
316        let dir = temp_dir();
317        let config_path = dir.path().join("settings.json");
318        let pause_path = dir.path().join("pause.json");
319        let events_path = dir.path().join("events.jsonl");
320        let screen_time_path = dir.path().join("screen_time.json");
321        let logger = Logger::spawn(events_path.clone());
322        let sched = Scheduler {
323            settings: Arc::new(TokioMutex::new(settings.clone())),
324            pause_state: Arc::new(TokioMutex::new(PauseState::Running)),
325            camera_active: Arc::new(AtomicBool::new(false)),
326            video_active: Arc::new(AtomicBool::new(false)),
327            auto_suppress_reason: Arc::new(AtomicU8::new(0)),
328            config_path,
329            pause_path,
330            events_path,
331            screen_time_path,
332            timers: Arc::new(TokioMutex::new(BreakTimers::new())),
333            stats: Arc::new(TokioMutex::new(BreakStats::default())),
334            screen_time: Arc::new(TokioMutex::new(ScreenTimeState::from_snapshot(
335                ScreenTimeSnapshot::default(),
336                "1970-01-01",
337            ))),
338            current_break: Arc::new(std::sync::Mutex::new(None)),
339            logger,
340            profiles: Arc::new(TokioMutex::new(vec![Profile {
341                name: DEFAULT_PROFILE_NAME.to_string(),
342                settings,
343            }])),
344            active_profile_name: Arc::new(TokioMutex::new(DEFAULT_PROFILE_NAME.to_string())),
345            hook_dialog_busy: Arc::new(AtomicBool::new(false)),
346        };
347        (dir, sched)
348    }
349
350    #[tokio::test]
351    async fn tray_countdown_snapshot_running_when_idle_and_text_enabled() {
352        // Fresh scheduler with the default interval settings → both timers
353        // anchored at construction → countdown is ~micro_interval_secs.
354        let s = Settings {
355            tray_countdown_enabled: true,
356            tray_countdown_target: "short".to_string(),
357            ..Settings::default()
358        };
359        let micro = s.micro_interval_secs;
360        let (_dir, sched) = build_test_scheduler(s);
361        let (snap, text_on) = sched.tray_countdown_snapshot().await;
362        assert!(text_on);
363        match snap {
364            TrayCountdownSnapshot::Running(secs) => {
365                // Allow a few seconds of slack for test execution overhead.
366                assert!(secs <= micro, "{secs} <= {micro}");
367                assert!(micro - secs < 5, "fresh anchor → close to full interval");
368            }
369            other => panic!("expected Running, got {other:?}"),
370        }
371    }
372
373    #[tokio::test]
374    async fn tray_countdown_snapshot_paused_when_scheduler_paused() {
375        let s = Settings {
376            tray_countdown_enabled: true,
377            ..Settings::default()
378        };
379        let (_dir, sched) = build_test_scheduler(s);
380        *sched.pause_state.lock().await = PauseState::PausedUntil(None);
381        let (snap, text_on) = sched.tray_countdown_snapshot().await;
382        assert!(text_on);
383        assert_eq!(snap, TrayCountdownSnapshot::Paused);
384    }
385
386    #[tokio::test]
387    async fn tray_countdown_snapshot_on_break_when_current_break_present() {
388        let s = Settings {
389            tray_countdown_enabled: true,
390            ..Settings::default()
391        };
392        let (_dir, sched) = build_test_scheduler(s);
393        *sched.current_break.lock().unwrap() = Some(InternalBreakEvent {
394            kind: BreakKind::Micro,
395            duration_secs: 30,
396            enforceable: false,
397            manual_finish: false,
398            postpone_available: true,
399            hints: vec![],
400            hint_rotate_seconds: 0,
401            health_intensity: 0.0,
402        });
403        let (snap, _) = sched.tray_countdown_snapshot().await;
404        assert_eq!(snap, TrayCountdownSnapshot::OnBreak);
405    }
406
407    #[tokio::test]
408    async fn tray_countdown_snapshot_disabled_when_text_off_and_no_visual_signal() {
409        let s = Settings {
410            tray_countdown_enabled: false,
411            ..Settings::default()
412        };
413        let (_dir, sched) = build_test_scheduler(s);
414        let (snap, text_on) = sched.tray_countdown_snapshot().await;
415        assert!(!text_on);
416        assert_eq!(snap, TrayCountdownSnapshot::Disabled);
417    }
418
419    #[tokio::test]
420    async fn tray_countdown_snapshot_suppressed_carries_auto_reason() {
421        // Auto-suppress encodes which guard fired via an AtomicU8; the
422        // snapshot must surface that reason even when text countdown is on.
423        let s = Settings {
424            tray_countdown_enabled: true,
425            ..Settings::default()
426        };
427        let (_dir, sched) = build_test_scheduler(s);
428        sched.auto_suppress_reason.store(
429            SuppressReason::Dnd.as_u8(),
430            std::sync::atomic::Ordering::Relaxed,
431        );
432        let (snap, _) = sched.tray_countdown_snapshot().await;
433        assert_eq!(snap, TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd));
434    }
435
436    #[tokio::test]
437    async fn tray_countdown_snapshot_idle_when_no_interval_modes_enabled() {
438        // Both kinds disabled → no interval-driven countdown → Idle.
439        let s = Settings {
440            tray_countdown_enabled: true,
441            micro_enabled: false,
442            long_enabled: false,
443            ..Settings::default()
444        };
445        let (_dir, sched) = build_test_scheduler(s);
446        let (snap, _) = sched.tray_countdown_snapshot().await;
447        assert_eq!(snap, TrayCountdownSnapshot::Idle);
448    }
449}