entracte_lib/scheduler/commands/
stats.rs1use chrono::Local;
2use tauri::{AppHandle, Emitter};
3use user_idle::UserIdle;
4
5use crate::stats;
6
7use super::super::break_stats::BreakStats;
8use super::super::screen_time::{persist_screen_time, rollover_if_new_day, ScreenTimeState};
9use super::super::timers::local_today_string;
10use super::super::types::BreakEvent;
11use super::super::Scheduler;
12
13#[tauri::command]
16pub async fn get_break_stats(scheduler: tauri::State<'_, Scheduler>) -> Result<BreakStats, String> {
17 Ok(scheduler.stats.lock().await.clone())
18}
19
20#[tauri::command]
24pub async fn reset_break_stats(
25 app: AppHandle,
26 scheduler: tauri::State<'_, Scheduler>,
27) -> Result<(), String> {
28 let snapshot = reset_and_snapshot_break_stats(&scheduler).await;
34 let _ = app.emit("stats:changed", &snapshot);
35 Ok(())
36}
37
38async fn reset_and_snapshot_break_stats(scheduler: &Scheduler) -> BreakStats {
43 reset_and_snapshot_break_stats_inner(&scheduler.stats).await
44}
45
46async fn reset_and_snapshot_break_stats_inner(
51 stats: &tokio::sync::Mutex<BreakStats>,
52) -> BreakStats {
53 let mut guard = stats.lock().await;
54 *guard = BreakStats::default();
55 guard.clone()
56}
57
58#[tauri::command]
63pub async fn get_stats_digest(
64 scheduler: tauri::State<'_, Scheduler>,
65 range: Option<String>,
66) -> Result<stats::Digest, String> {
67 let range = range.unwrap_or_else(|| "week".to_string());
68 let events = stats::read_all(&scheduler.events_path);
69 Ok(stats::compute_digest(&events, &range, Local::now()))
70}
71
72#[tauri::command]
75pub async fn export_stats_csv(scheduler: tauri::State<'_, Scheduler>) -> Result<String, String> {
76 let events = stats::read_all(&scheduler.events_path);
77 Ok(stats::export_csv(&events))
78}
79
80#[tauri::command]
84pub fn get_idle_secs() -> Result<u64, String> {
85 UserIdle::get_time()
86 .map(|i| i.as_seconds())
87 .map_err(|e| e.to_string())
88}
89
90#[tauri::command]
94pub async fn get_screen_time(
95 scheduler: tauri::State<'_, Scheduler>,
96) -> Result<ScreenTimeState, String> {
97 let today = local_today_string();
98 let mut st = scheduler.screen_time.lock().await;
99 if rollover_if_new_day(&mut st, &today) {
100 persist_screen_time(&scheduler.screen_time_path, &st);
101 }
102 Ok(st.clone())
103}
104
105#[tauri::command]
109pub async fn clear_event_log(
110 app: AppHandle,
111 scheduler: tauri::State<'_, Scheduler>,
112) -> Result<(), String> {
113 stats::clear_log(&scheduler.events_path, scheduler.logger.write_lock())
114 .map_err(|e| e.to_string())?;
115 let _ = app.emit("stats:cleared", ());
116 Ok(())
117}
118
119#[tauri::command]
123pub fn get_current_break(
124 scheduler: tauri::State<'_, Scheduler>,
125) -> Result<Option<BreakEvent>, String> {
126 Ok(scheduler.current_break.lock().ok().and_then(|s| s.clone()))
127}
128
129#[cfg(test)]
133impl PartialEq for BreakStats {
134 fn eq(&self, other: &Self) -> bool {
135 self.taken == other.taken
136 && self.skipped == other.skipped
137 && self.postponed == other.postponed
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use std::sync::Arc;
144 use std::time::Duration;
145
146 use tokio::sync::Mutex;
147
148 use super::*;
149
150 #[tokio::test]
159 async fn reset_and_snapshot_holds_lock_across_clone() {
160 let stats = Arc::new(Mutex::new(BreakStats {
161 taken: 5,
162 skipped: 2,
163 postponed: 1,
164 }));
165
166 let stats_writer = stats.clone();
167 let writer = tokio::spawn(async move {
170 let mut g = stats_writer.lock().await;
171 g.taken = g.taken.saturating_add(1);
172 });
173
174 let snapshot = reset_and_snapshot_break_stats_inner(&stats).await;
175
176 assert_eq!(snapshot.taken, 0, "emitted payload must reflect reset");
179 assert_eq!(snapshot.skipped, 0);
180 assert_eq!(snapshot.postponed, 0);
181
182 writer.await.unwrap();
184 let final_state = stats.lock().await.clone();
185 assert_eq!(
186 final_state.taken, 1,
187 "writer's increment lands after the reset, not before"
188 );
189 }
190
191 #[tokio::test]
192 async fn reset_and_snapshot_under_repeated_contention() {
193 let stats = Arc::new(Mutex::new(BreakStats::default()));
198
199 let mut writers = Vec::new();
200 for _ in 0..20 {
201 let s = stats.clone();
202 writers.push(tokio::spawn(async move {
203 tokio::time::sleep(Duration::from_micros(50)).await;
204 let mut g = s.lock().await;
205 g.taken = g.taken.saturating_add(1);
206 }));
207 }
208
209 let mut resetters = Vec::new();
210 for _ in 0..20 {
211 let s = stats.clone();
212 resetters.push(tokio::spawn(async move {
213 reset_and_snapshot_break_stats_inner(&s).await
214 }));
215 }
216
217 for r in resetters {
218 let snap = r.await.unwrap();
219 assert_eq!(
220 snap,
221 BreakStats::default(),
222 "snapshot must always be the zero-state, never a partial increment",
223 );
224 }
225 for w in writers {
226 w.await.unwrap();
227 }
228 }
229}