entracte_lib/scheduler/commands/
stats.rs1use chrono::Local;
2use tauri::{AppHandle, Emitter, Runtime};
3
4use crate::stats;
5
6use super::super::break_stats::BreakStats;
7use super::super::screen_time::{persist_screen_time, rollover_if_new_day, ScreenTimeState};
8use super::super::timers::local_today_string;
9use super::super::types::BreakEvent;
10use super::super::Scheduler;
11
12#[tauri::command]
15pub async fn get_break_stats(scheduler: tauri::State<'_, Scheduler>) -> Result<BreakStats, String> {
16 Ok(scheduler.stats.lock().await.clone())
17}
18
19#[tauri::command]
23pub async fn reset_break_stats<R: Runtime>(
24 app: AppHandle<R>,
25 scheduler: tauri::State<'_, Scheduler>,
26) -> Result<(), String> {
27 let snapshot = reset_and_snapshot_break_stats(&scheduler).await;
33 let _ = app.emit("stats:changed", &snapshot);
34 Ok(())
35}
36
37async fn reset_and_snapshot_break_stats(scheduler: &Scheduler) -> BreakStats {
42 reset_and_snapshot_break_stats_inner(&scheduler.stats).await
43}
44
45async fn reset_and_snapshot_break_stats_inner(
50 stats: &tokio::sync::Mutex<BreakStats>,
51) -> BreakStats {
52 let mut guard = stats.lock().await;
53 *guard = BreakStats::default();
54 guard.clone()
55}
56
57#[tauri::command]
62pub async fn get_stats_digest(
63 scheduler: tauri::State<'_, Scheduler>,
64 range: Option<String>,
65) -> Result<stats::Digest, String> {
66 let range = range.unwrap_or_else(|| "week".to_string());
67 let events = stats::read_all(&scheduler.events_path);
68 Ok(stats::compute_digest(&events, &range, Local::now()))
69}
70
71#[tauri::command]
74pub async fn export_stats_csv(scheduler: tauri::State<'_, Scheduler>) -> Result<String, String> {
75 let events = stats::read_all(&scheduler.events_path);
76 Ok(stats::export_csv(&events))
77}
78
79#[tauri::command]
83pub fn get_idle_secs() -> Result<u64, String> {
84 super::super::idle::idle_secs()
85}
86
87#[tauri::command]
91pub async fn get_screen_time(
92 scheduler: tauri::State<'_, Scheduler>,
93) -> Result<ScreenTimeState, String> {
94 let today = local_today_string();
95 let mut st = scheduler.screen_time.lock().await;
96 if rollover_if_new_day(&mut st, &today) {
97 persist_screen_time(&scheduler.screen_time_path, &st);
98 }
99 Ok(st.clone())
100}
101
102#[tauri::command]
106pub async fn clear_event_log<R: Runtime>(
107 app: AppHandle<R>,
108 scheduler: tauri::State<'_, Scheduler>,
109) -> Result<(), String> {
110 stats::clear_log(&scheduler.events_path, scheduler.logger.write_lock())
111 .map_err(|e| e.to_string())?;
112 let _ = app.emit("stats:cleared", ());
113 Ok(())
114}
115
116#[tauri::command]
120pub fn get_current_break(
121 scheduler: tauri::State<'_, Scheduler>,
122) -> Result<Option<BreakEvent>, String> {
123 Ok(super::super::lock_current_break(&scheduler.current_break).clone())
124}
125
126#[cfg(test)]
130impl PartialEq for BreakStats {
131 fn eq(&self, other: &Self) -> bool {
132 self.taken == other.taken
133 && self.skipped == other.skipped
134 && self.postponed == other.postponed
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use std::sync::Arc;
141 use std::time::Duration;
142
143 use tokio::sync::Mutex;
144
145 use super::*;
146
147 #[tokio::test]
156 async fn reset_and_snapshot_holds_lock_across_clone() {
157 let stats = Arc::new(Mutex::new(BreakStats {
158 taken: 5,
159 skipped: 2,
160 postponed: 1,
161 }));
162
163 let stats_writer = stats.clone();
164 let writer = tokio::spawn(async move {
167 let mut g = stats_writer.lock().await;
168 g.taken = g.taken.saturating_add(1);
169 });
170
171 let snapshot = reset_and_snapshot_break_stats_inner(&stats).await;
172
173 assert_eq!(snapshot.taken, 0, "emitted payload must reflect reset");
176 assert_eq!(snapshot.skipped, 0);
177 assert_eq!(snapshot.postponed, 0);
178
179 writer.await.unwrap();
181 let final_state = stats.lock().await.clone();
182 assert_eq!(
183 final_state.taken, 1,
184 "writer's increment lands after the reset, not before"
185 );
186 }
187
188 #[tokio::test]
189 async fn reset_and_snapshot_under_repeated_contention() {
190 let stats = Arc::new(Mutex::new(BreakStats::default()));
195
196 let mut writers = Vec::new();
197 for _ in 0..20 {
198 let s = stats.clone();
199 writers.push(tokio::spawn(async move {
200 tokio::time::sleep(Duration::from_micros(50)).await;
201 let mut g = s.lock().await;
202 g.taken = g.taken.saturating_add(1);
203 }));
204 }
205
206 let mut resetters = Vec::new();
207 for _ in 0..20 {
208 let s = stats.clone();
209 resetters.push(tokio::spawn(async move {
210 reset_and_snapshot_break_stats_inner(&s).await
211 }));
212 }
213
214 for r in resetters {
215 let snap = r.await.unwrap();
216 assert_eq!(
217 snap,
218 BreakStats::default(),
219 "snapshot must always be the zero-state, never a partial increment",
220 );
221 }
222 for w in writers {
223 w.await.unwrap();
224 }
225 }
226}
227
228#[cfg(all(test, not(target_os = "windows")))]
236mod rig_smoke_tests {
237 use super::*;
238 use crate::scheduler::Settings;
239 use crate::test_support::mock_app_with_scheduler;
240 use std::sync::atomic::{AtomicUsize, Ordering};
241 use std::sync::Arc;
242 use tauri::{Listener, Manager};
243
244 #[tokio::test]
245 async fn reset_break_stats_command_via_rig_emits_stats_changed() {
246 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
247 {
249 let mut s = sched.stats.lock().await;
250 s.taken = 3;
251 s.skipped = 2;
252 }
253
254 let captured: Arc<std::sync::Mutex<Option<serde_json::Value>>> =
255 Arc::new(std::sync::Mutex::new(None));
256 {
257 let captured = captured.clone();
258 app.listen("stats:changed", move |event| {
259 let v: serde_json::Value =
260 serde_json::from_str(event.payload()).expect("stats:changed payload is JSON");
261 *captured.lock().unwrap() = Some(v);
262 });
263 }
264
265 let state = app.state::<Scheduler>();
266 reset_break_stats(app.handle().clone(), state)
267 .await
268 .expect("reset_break_stats succeeds");
269
270 let emitted = captured
271 .lock()
272 .unwrap()
273 .clone()
274 .expect("stats:changed was emitted");
275 assert_eq!(emitted["taken"], 0, "emit ships post-reset taken");
276 assert_eq!(emitted["skipped"], 0, "emit ships post-reset skipped");
277 assert_eq!(emitted["postponed"], 0);
278 let live = sched.stats.lock().await;
279 assert_eq!(live.taken, 0);
280 assert_eq!(live.skipped, 0);
281 }
282
283 #[tokio::test]
284 async fn clear_event_log_command_via_rig_emits_stats_cleared() {
285 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
286 std::fs::write(&sched.events_path, b"{\"type\":\"pause_start\"}\n").unwrap();
289
290 let fired = Arc::new(AtomicUsize::new(0));
291 {
292 let fired = fired.clone();
293 app.listen("stats:cleared", move |_event| {
294 fired.fetch_add(1, Ordering::SeqCst);
295 });
296 }
297
298 let state = app.state::<Scheduler>();
299 clear_event_log(app.handle().clone(), state)
300 .await
301 .expect("clear_event_log succeeds");
302
303 assert_eq!(fired.load(Ordering::SeqCst), 1, "stats:cleared fired once");
304 let leftovers = crate::stats::read_all(&sched.events_path);
307 assert!(leftovers.is_empty(), "event log emptied by clear");
308 }
309}