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#[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#[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#[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 #[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 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#[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 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 assert_eq!(
185 decide_tray_snapshot(false, false, true, false, None, None),
186 TrayCountdownSnapshot::Bedtime,
187 );
188 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 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 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 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 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 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 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 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}