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::{BreakKind, 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 = super::lock_current_break(&self.current_break).is_some();
83        let suppress_reason =
84            SuppressReason::from_u8(self.auto_suppress_reason.load(Ordering::Relaxed));
85
86        // Only compute the time-to-next-break when the result would be used;
87        // visual-mode snapshots (paused / bedtime / on-break / suppressed) win.
88        let countdown_secs = if paused || bedtime_active || on_break || suppress_reason.is_some() {
89            None
90        } else {
91            let t = self.timers.lock().await;
92            let now = Instant::now();
93            let micro_secs = if s.micro_enabled && s.interval_active(BreakKind::Micro) {
94                let elapsed = now.saturating_duration_since(t.last_micro).as_secs();
95                Some(s.micro_interval_secs.saturating_sub(elapsed))
96            } else {
97                None
98            };
99            let long_secs = if s.long_enabled && s.interval_active(BreakKind::Long) {
100                let elapsed = now.saturating_duration_since(t.last_long).as_secs();
101                Some(s.long_interval_secs.saturating_sub(elapsed))
102            } else {
103                None
104            };
105            pick_countdown_secs(&s.tray_countdown_target, micro_secs, long_secs)
106        };
107
108        let snapshot = decide_tray_snapshot(
109            s.tray_countdown_enabled,
110            paused,
111            bedtime_active,
112            on_break,
113            suppress_reason,
114            countdown_secs,
115        );
116        (snapshot, s.tray_countdown_enabled)
117    }
118}
119
120// Pure decision tree for the tray snapshot. Visual-mode signals (paused,
121// bedtime, on-break, auto-suppressed) take precedence over the text-countdown
122// gate, so the icon swaps even when `tray_countdown_enabled` is false. Only
123// the Idle / Running text states honour that flag.
124#[cfg_attr(target_os = "windows", allow(dead_code))]
125fn decide_tray_snapshot(
126    text_enabled: bool,
127    paused: bool,
128    bedtime_active: bool,
129    on_break: bool,
130    suppress_reason: Option<SuppressReason>,
131    countdown_secs: Option<u64>,
132) -> TrayCountdownSnapshot {
133    if paused {
134        return TrayCountdownSnapshot::Paused;
135    }
136    if bedtime_active {
137        return TrayCountdownSnapshot::Bedtime;
138    }
139    if on_break {
140        return TrayCountdownSnapshot::OnBreak;
141    }
142    if let Some(reason) = suppress_reason {
143        return TrayCountdownSnapshot::Suppressed(reason);
144    }
145    if !text_enabled {
146        return TrayCountdownSnapshot::Disabled;
147    }
148    match countdown_secs {
149        Some(s) => TrayCountdownSnapshot::Running(s),
150        None => TrayCountdownSnapshot::Idle,
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn decide_tray_snapshot_paused_wins_over_everything() {
160        // Pause is an explicit user action — it takes precedence even over
161        // bedtime / on-break, so the user always sees a clear "paused" signal.
162        assert_eq!(
163            decide_tray_snapshot(true, true, true, true, Some(SuppressReason::Dnd), Some(60)),
164            TrayCountdownSnapshot::Paused,
165        );
166        assert_eq!(
167            decide_tray_snapshot(false, true, false, false, None, None),
168            TrayCountdownSnapshot::Paused,
169        );
170    }
171
172    #[test]
173    fn decide_tray_snapshot_bedtime_ignores_text_countdown_gate() {
174        // Regression test for the bug where `tray_countdown_enabled=false`
175        // short-circuited the snapshot and the bedtime icon never swapped.
176        assert_eq!(
177            decide_tray_snapshot(false, false, true, false, None, None),
178            TrayCountdownSnapshot::Bedtime,
179        );
180        // Bedtime also outranks on-break + auto-suppressed.
181        assert_eq!(
182            decide_tray_snapshot(
183                true,
184                false,
185                true,
186                true,
187                Some(SuppressReason::Video),
188                Some(60)
189            ),
190            TrayCountdownSnapshot::Bedtime,
191        );
192    }
193
194    #[test]
195    fn decide_tray_snapshot_on_break_beats_suppressed_but_not_bedtime() {
196        assert_eq!(
197            decide_tray_snapshot(true, false, false, true, Some(SuppressReason::Camera), None),
198            TrayCountdownSnapshot::OnBreak,
199        );
200    }
201
202    #[test]
203    fn decide_tray_snapshot_suppressed_ignores_text_countdown_gate() {
204        // Same fix as bedtime — suppressed states drive the dim icon and must
205        // surface even when the countdown text is off. Carries the reason.
206        assert_eq!(
207            decide_tray_snapshot(false, false, false, false, Some(SuppressReason::Dnd), None),
208            TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd),
209        );
210    }
211
212    #[test]
213    fn decide_tray_snapshot_suppressed_carries_each_reason_through() {
214        for r in [
215            SuppressReason::WorkWindow,
216            SuppressReason::Dnd,
217            SuppressReason::Camera,
218            SuppressReason::Video,
219            SuppressReason::AppPause,
220        ] {
221            assert_eq!(
222                decide_tray_snapshot(true, false, false, false, Some(r), None),
223                TrayCountdownSnapshot::Suppressed(r),
224                "{r:?} should round-trip through decide_tray_snapshot",
225            );
226        }
227    }
228
229    #[test]
230    fn decide_tray_snapshot_disabled_only_when_no_visual_signal() {
231        // With text disabled and no visual signal active, return Disabled
232        // (renders the normal icon with no title text).
233        assert_eq!(
234            decide_tray_snapshot(false, false, false, false, None, Some(60)),
235            TrayCountdownSnapshot::Disabled,
236        );
237    }
238
239    #[test]
240    fn decide_tray_snapshot_running_when_text_enabled_and_idle_otherwise() {
241        assert_eq!(
242            decide_tray_snapshot(true, false, false, false, None, Some(125)),
243            TrayCountdownSnapshot::Running(125),
244        );
245        assert_eq!(
246            decide_tray_snapshot(true, false, false, false, None, None),
247            TrayCountdownSnapshot::Idle,
248        );
249    }
250
251    #[test]
252    fn pick_countdown_secs_target_short() {
253        assert_eq!(pick_countdown_secs("short", Some(60), Some(900)), Some(60));
254        assert_eq!(pick_countdown_secs("short", None, Some(900)), None);
255    }
256
257    #[test]
258    fn pick_countdown_secs_target_long() {
259        assert_eq!(pick_countdown_secs("long", Some(60), Some(900)), Some(900));
260        assert_eq!(pick_countdown_secs("long", Some(60), None), None);
261    }
262
263    #[test]
264    fn pick_countdown_secs_target_next_picks_min() {
265        assert_eq!(pick_countdown_secs("next", Some(60), Some(900)), Some(60));
266        assert_eq!(pick_countdown_secs("next", Some(900), Some(60)), Some(60));
267        assert_eq!(pick_countdown_secs("next", Some(120), None), Some(120));
268        assert_eq!(pick_countdown_secs("next", None, Some(120)), Some(120));
269        assert_eq!(pick_countdown_secs("next", None, None), None);
270        assert_eq!(pick_countdown_secs("garbage", Some(5), Some(9)), Some(5));
271    }
272
273    #[test]
274    fn format_countdown_under_ten_minutes() {
275        assert_eq!(format_countdown(0), "0:00");
276        assert_eq!(format_countdown(5), "0:05");
277        assert_eq!(format_countdown(59), "0:59");
278        assert_eq!(format_countdown(60), "1:00");
279        assert_eq!(format_countdown(125), "2:05");
280        assert_eq!(format_countdown(9 * 60 + 59), "9:59");
281    }
282
283    #[test]
284    fn format_countdown_ten_minutes_or_more() {
285        assert_eq!(format_countdown(10 * 60), "10:00");
286        assert_eq!(format_countdown(12 * 60 + 34), "12:34");
287        assert_eq!(format_countdown(59 * 60 + 59), "59:59");
288        assert_eq!(format_countdown(60 * 60), "60:00");
289    }
290
291    // ----- Scheduler::tray_countdown_snapshot integration -----
292
293    use crate::config::{Profile, DEFAULT_PROFILE_NAME};
294    use crate::scheduler::break_stats::BreakStats;
295    use crate::scheduler::chores::ChoresState;
296    use crate::scheduler::screen_time::ScreenTimeState;
297    use crate::scheduler::settings::Settings;
298    use crate::scheduler::timers::BreakTimers;
299    use crate::scheduler::types::BreakEvent as InternalBreakEvent;
300    use crate::scheduler::types::BreakKind;
301    use crate::screen_time_store::ScreenTimeSnapshot;
302    use crate::stats::Logger;
303    use crate::test_support::{temp_dir, TempDir};
304    use std::sync::atomic::{AtomicBool, AtomicU8};
305    use std::sync::Arc;
306    use tokio::sync::Mutex as TokioMutex;
307
308    fn build_test_scheduler(settings: Settings) -> (TempDir, Scheduler) {
309        let dir = temp_dir();
310        let config_path = dir.path().join("settings.json");
311        let pause_path = dir.path().join("pause.json");
312        let events_path = dir.path().join("events.jsonl");
313        let screen_time_path = dir.path().join("screen_time.json");
314        let chores_path = dir.path().join("chores.json");
315        let logger = Logger::spawn(events_path.clone());
316        let sched = Scheduler {
317            settings: Arc::new(TokioMutex::new(settings.clone())),
318            pause_state: Arc::new(TokioMutex::new(PauseState::Running)),
319            camera_active: Arc::new(AtomicBool::new(false)),
320            video_active: Arc::new(AtomicBool::new(false)),
321            plugin_suppress: Arc::new(AtomicBool::new(false)),
322            auto_suppress_reason: Arc::new(AtomicU8::new(0)),
323            config_path,
324            pause_path,
325            events_path,
326            screen_time_path,
327            chores_path,
328            plugins_path: dir.path().join("plugins.json"),
329            plugins: Arc::new(TokioMutex::new(crate::plugins::PluginRegistry::default())),
330            plugin_dialog_busy: Arc::new(AtomicBool::new(false)),
331            timers: Arc::new(TokioMutex::new(BreakTimers::new())),
332            stats: Arc::new(TokioMutex::new(BreakStats::default())),
333            screen_time: Arc::new(TokioMutex::new(ScreenTimeState::from_snapshot(
334                ScreenTimeSnapshot::default(),
335                "1970-01-01",
336            ))),
337            chores: Arc::new(TokioMutex::new(ChoresState::default())),
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            onboarding_completed: Arc::new(AtomicBool::new(true)),
347            import_in_progress: Arc::new(AtomicBool::new(false)),
348        };
349        (dir, sched)
350    }
351
352    #[tokio::test]
353    async fn tray_countdown_snapshot_running_when_idle_and_text_enabled() {
354        // Fresh scheduler with the default interval settings → both timers
355        // anchored at construction → countdown is ~micro_interval_secs.
356        let s = Settings {
357            tray_countdown_enabled: true,
358            tray_countdown_target: "short".to_string(),
359            ..Settings::default()
360        };
361        let micro = s.micro_interval_secs;
362        let (_dir, sched) = build_test_scheduler(s);
363        let (snap, text_on) = sched.tray_countdown_snapshot().await;
364        assert!(text_on);
365        match snap {
366            TrayCountdownSnapshot::Running(secs) => {
367                // Allow a few seconds of slack for test execution overhead.
368                assert!(secs <= micro, "{secs} <= {micro}");
369                assert!(micro - secs < 5, "fresh anchor → close to full interval");
370            }
371            other => panic!("expected Running, got {other:?}"),
372        }
373    }
374
375    #[tokio::test]
376    async fn tray_countdown_snapshot_paused_when_scheduler_paused() {
377        let s = Settings {
378            tray_countdown_enabled: true,
379            ..Settings::default()
380        };
381        let (_dir, sched) = build_test_scheduler(s);
382        *sched.pause_state.lock().await = PauseState::PausedUntil(None);
383        let (snap, text_on) = sched.tray_countdown_snapshot().await;
384        assert!(text_on);
385        assert_eq!(snap, TrayCountdownSnapshot::Paused);
386    }
387
388    #[tokio::test]
389    async fn tray_countdown_snapshot_on_break_when_current_break_present() {
390        let s = Settings {
391            tray_countdown_enabled: true,
392            ..Settings::default()
393        };
394        let (_dir, sched) = build_test_scheduler(s);
395        *sched.current_break.lock().unwrap() = Some(InternalBreakEvent {
396            kind: BreakKind::Micro,
397            duration_secs: 30,
398            enforceable: false,
399            manual_finish: false,
400            postpone_available: true,
401            skip_available: true,
402            hints: vec![],
403            hint_rotate_seconds: 0,
404            health_intensity: 0.0,
405            routine_steps: vec![],
406            routine_pacing: None,
407            routine_max_step_secs: None,
408            routine_breath: None,
409            chore_prompt: None,
410        });
411        let (snap, _) = sched.tray_countdown_snapshot().await;
412        assert_eq!(snap, TrayCountdownSnapshot::OnBreak);
413    }
414
415    #[tokio::test]
416    async fn tray_countdown_snapshot_disabled_when_text_off_and_no_visual_signal() {
417        let s = Settings {
418            tray_countdown_enabled: false,
419            ..Settings::default()
420        };
421        let (_dir, sched) = build_test_scheduler(s);
422        let (snap, text_on) = sched.tray_countdown_snapshot().await;
423        assert!(!text_on);
424        assert_eq!(snap, TrayCountdownSnapshot::Disabled);
425    }
426
427    #[tokio::test]
428    async fn tray_countdown_snapshot_suppressed_carries_auto_reason() {
429        // Auto-suppress encodes which guard fired via an AtomicU8; the
430        // snapshot must surface that reason even when text countdown is on.
431        let s = Settings {
432            tray_countdown_enabled: true,
433            ..Settings::default()
434        };
435        let (_dir, sched) = build_test_scheduler(s);
436        sched.auto_suppress_reason.store(
437            SuppressReason::Dnd.as_u8(),
438            std::sync::atomic::Ordering::Relaxed,
439        );
440        let (snap, _) = sched.tray_countdown_snapshot().await;
441        assert_eq!(snap, TrayCountdownSnapshot::Suppressed(SuppressReason::Dnd));
442    }
443
444    #[tokio::test]
445    async fn tray_countdown_snapshot_idle_when_no_interval_modes_enabled() {
446        // Both kinds disabled → no interval-driven countdown → Idle.
447        let s = Settings {
448            tray_countdown_enabled: true,
449            micro_enabled: false,
450            long_enabled: false,
451            ..Settings::default()
452        };
453        let (_dir, sched) = build_test_scheduler(s);
454        let (snap, _) = sched.tray_countdown_snapshot().await;
455        assert_eq!(snap, TrayCountdownSnapshot::Idle);
456    }
457}