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#[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 = 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 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#[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 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 assert_eq!(
177 decide_tray_snapshot(false, false, true, false, None, None),
178 TrayCountdownSnapshot::Bedtime,
179 );
180 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 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 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 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 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 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 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 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}