Skip to main content

entracte_lib/scheduler/commands/
stats.rs

1use 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/// In-session counters (taken / skipped / postponed). Reset on
13/// every scheduler start.
14#[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/// Zero out the in-session counters and emit `stats:changed`.
20/// The persistent event log under `events.jsonl` is untouched —
21/// `clear_event_log` does that.
22#[tauri::command]
23pub async fn reset_break_stats<R: Runtime>(
24    app: AppHandle<R>,
25    scheduler: tauri::State<'_, Scheduler>,
26) -> Result<(), String> {
27    // Snapshot under the lock so a concurrent `BreakStart` increment
28    // can't slip in between the reset and the emit and ship a
29    // post-reset count to the renderer (regression: the old code
30    // dropped the guard, re-took it, and the racing writer could land
31    // in the gap).
32    let snapshot = reset_and_snapshot_break_stats(&scheduler).await;
33    let _ = app.emit("stats:changed", &snapshot);
34    Ok(())
35}
36
37/// Atomically replace the scheduler's in-session counters with their
38/// default and return the snapshot the emit should ship. Extracted as
39/// a separate helper so the lock-then-snapshot ordering can be tested
40/// against a real concurrent writer.
41async fn reset_and_snapshot_break_stats(scheduler: &Scheduler) -> BreakStats {
42    reset_and_snapshot_break_stats_inner(&scheduler.stats).await
43}
44
45/// Pure-ish helper: zero the cell under the supplied mutex and return
46/// a snapshot of the post-reset value, both atomic to outside writers.
47/// Tested in isolation because `Scheduler` is not constructible in unit
48/// tests (it spawns camera/video monitor threads at boot).
49async 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/// Aggregate the persistent event log into a digest for the Insights
58/// tab. `range` is `"week"` (default) or `"month"`. Reads `events.jsonl`
59/// every call — small enough to be cheap, large enough that the
60/// renderer should debounce range toggles.
61#[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/// Serialise every persisted event as a CSV string. The renderer
72/// hands the result to a Blob → download for "Export CSV" on Insights.
73#[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/// Seconds since the last keyboard/mouse input. Used by the overlay
80/// to drive the typing-pause feature: while the user is mid-keystroke
81/// the countdown is paused.
82#[tauri::command]
83pub fn get_idle_secs() -> Result<u64, String> {
84    super::super::idle::idle_secs()
85}
86
87/// Today's accumulated screen time + the last-reminder marker.
88/// Rolls over to a fresh day if local midnight has passed since the
89/// last call.
90#[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/// Delete the persistent `events.jsonl` log (the "Clear history"
103/// button on Insights). In-session counters are unaffected. Emits
104/// `stats:cleared` so the renderer can refresh.
105#[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/// Snapshot of the in-flight break event, or `None` between breaks.
117/// Used by the overlay on cold-mount so it can re-render the right
118/// state if the window was reloaded mid-break.
119#[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// `BreakStats` doesn't derive `PartialEq` in production (no consumer
127// compares them outside tests). Add it under cfg(test) so the contention
128// assertion in the test module can match against the default snapshot.
129#[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    // Fix #4 regression: the old `reset_break_stats` dropped the guard
148    // between writing the default and re-reading for the emit, so a
149    // concurrent `BreakStart` increment landing in that gap would ship a
150    // post-reset count to the renderer. The fixed helper holds the lock
151    // across the snapshot. We assert that by racing a writer that grabs
152    // the lock as soon as the resetter releases it: the emitted payload
153    // must still show the reset state, and the writer's mutation must
154    // land *after* the reset.
155    #[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        // Spin up a contender that wants to increment `taken` the
165        // instant the lock becomes available.
166        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        // The snapshot must be the post-reset state, regardless of when
174        // the writer scheduled its increment.
175        assert_eq!(snapshot.taken, 0, "emitted payload must reflect reset");
176        assert_eq!(snapshot.skipped, 0);
177        assert_eq!(snapshot.postponed, 0);
178
179        // Let the writer run; it lands AFTER the reset's snapshot.
180        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        // Stress-test the lock ordering: fire many resetters and writers
191        // concurrently and confirm every emitted snapshot is the
192        // zero-state. The bug would surface as some snapshots carrying
193        // increments from racing writers.
194        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// =====================================================================
229// Integration-test rig: drives `reset_break_stats` and `clear_event_log`
230// (now generic over `R: Runtime`) end-to-end so the AppHandle-emit
231// branches are covered. The impl-level lock-ordering tests above stay
232// the canonical source for race-condition coverage; these only verify
233// the wrapper threads through and fires its event.
234// =====================================================================
235#[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        // Pre-populate so the reset is observable.
248        {
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        // Seed the log so clear has something to do (and verify the
287        // file is gone after).
288        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        // The log file is gone (or empty) after clear — `read_all`
305        // tolerates both.
306        let leftovers = crate::stats::read_all(&sched.events_path);
307        assert!(leftovers.is_empty(), "event log emptied by clear");
308    }
309}