Skip to main content

entracte_lib/scheduler/commands/
stats.rs

1use 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/// In-session counters (taken / skipped / postponed). Reset on
14/// every scheduler start.
15#[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/// Zero out the in-session counters and emit `stats:changed`.
21/// The persistent event log under `events.jsonl` is untouched —
22/// `clear_event_log` does that.
23#[tauri::command]
24pub async fn reset_break_stats(
25    app: AppHandle,
26    scheduler: tauri::State<'_, Scheduler>,
27) -> Result<(), String> {
28    // Snapshot under the lock so a concurrent `BreakStart` increment
29    // can't slip in between the reset and the emit and ship a
30    // post-reset count to the renderer (regression: the old code
31    // dropped the guard, re-took it, and the racing writer could land
32    // in the gap).
33    let snapshot = reset_and_snapshot_break_stats(&scheduler).await;
34    let _ = app.emit("stats:changed", &snapshot);
35    Ok(())
36}
37
38/// Atomically replace the scheduler's in-session counters with their
39/// default and return the snapshot the emit should ship. Extracted as
40/// a separate helper so the lock-then-snapshot ordering can be tested
41/// against a real concurrent writer.
42async fn reset_and_snapshot_break_stats(scheduler: &Scheduler) -> BreakStats {
43    reset_and_snapshot_break_stats_inner(&scheduler.stats).await
44}
45
46/// Pure-ish helper: zero the cell under the supplied mutex and return
47/// a snapshot of the post-reset value, both atomic to outside writers.
48/// Tested in isolation because `Scheduler` is not constructible in unit
49/// tests (it spawns camera/video monitor threads at boot).
50async 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/// Aggregate the persistent event log into a digest for the Insights
59/// tab. `range` is `"week"` (default) or `"month"`. Reads `events.jsonl`
60/// every call — small enough to be cheap, large enough that the
61/// renderer should debounce range toggles.
62#[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/// Serialise every persisted event as a CSV string. The renderer
73/// hands the result to a Blob → download for "Export CSV" on Insights.
74#[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/// Seconds since the last keyboard/mouse input. Used by the overlay
81/// to drive the typing-pause feature: while the user is mid-keystroke
82/// the countdown is paused.
83#[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/// Today's accumulated screen time + the last-reminder marker.
91/// Rolls over to a fresh day if local midnight has passed since the
92/// last call.
93#[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/// Delete the persistent `events.jsonl` log (the "Clear history"
106/// button on Insights). In-session counters are unaffected. Emits
107/// `stats:cleared` so the renderer can refresh.
108#[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/// Snapshot of the in-flight break event, or `None` between breaks.
120/// Used by the overlay on cold-mount so it can re-render the right
121/// state if the window was reloaded mid-break.
122#[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// `BreakStats` doesn't derive `PartialEq` in production (no consumer
130// compares them outside tests). Add it under cfg(test) so the contention
131// assertion in the test module can match against the default snapshot.
132#[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    // Fix #4 regression: the old `reset_break_stats` dropped the guard
151    // between writing the default and re-reading for the emit, so a
152    // concurrent `BreakStart` increment landing in that gap would ship a
153    // post-reset count to the renderer. The fixed helper holds the lock
154    // across the snapshot. We assert that by racing a writer that grabs
155    // the lock as soon as the resetter releases it: the emitted payload
156    // must still show the reset state, and the writer's mutation must
157    // land *after* the reset.
158    #[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        // Spin up a contender that wants to increment `taken` the
168        // instant the lock becomes available.
169        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        // The snapshot must be the post-reset state, regardless of when
177        // the writer scheduled its increment.
178        assert_eq!(snapshot.taken, 0, "emitted payload must reflect reset");
179        assert_eq!(snapshot.skipped, 0);
180        assert_eq!(snapshot.postponed, 0);
181
182        // Let the writer run; it lands AFTER the reset's snapshot.
183        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        // Stress-test the lock ordering: fire many resetters and writers
194        // concurrently and confirm every emitted snapshot is the
195        // zero-state. The bug would surface as some snapshots carrying
196        // increments from racing writers.
197        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}