Skip to main content

entracte_lib/
stats.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use chrono::{DateTime, Duration, Local, NaiveDate, Timelike, Utc};
6use log::error;
7use serde::{Deserialize, Serialize};
8use tokio::sync::mpsc;
9
10use crate::scheduler::BreakKind;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case")]
14pub enum Outcome {
15    Completed,
16    Dismissed,
17}
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum SkipSource {
22    User,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
26#[serde(rename_all = "snake_case")]
27pub enum GuardReason {
28    Dnd,
29    Camera,
30    Idle,
31    AppPause,
32    Typing,
33    Video,
34}
35
36impl GuardReason {
37    fn label(self) -> &'static str {
38        match self {
39            GuardReason::Dnd => "Do Not Disturb",
40            GuardReason::Camera => "Camera in use",
41            GuardReason::Idle => "Idle",
42            GuardReason::AppPause => "Paused-app running",
43            GuardReason::Typing => "Actively typing",
44            GuardReason::Video => "Video playing",
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum EventPayload {
52    BreakStart {
53        kind: BreakKind,
54        duration_secs: u64,
55        enforceable: bool,
56    },
57    BreakEnd {
58        kind: BreakKind,
59        outcome: Outcome,
60    },
61    BreakPostponed {
62        kind: BreakKind,
63        minutes: u32,
64    },
65    BreakSkipped {
66        kind: BreakKind,
67        source: SkipSource,
68    },
69    BreakResumed {
70        kind: BreakKind,
71    },
72    PauseStart {
73        duration_secs: Option<u64>,
74    },
75    PauseEnd,
76    GuardSuppress {
77        kind: BreakKind,
78        reason: GuardReason,
79    },
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct LoggedEvent {
84    pub t: DateTime<Utc>,
85    #[serde(flatten)]
86    pub event: EventPayload,
87}
88
89impl LoggedEvent {
90    pub fn now(event: EventPayload) -> Self {
91        Self {
92            t: Utc::now(),
93            event,
94        }
95    }
96}
97
98/// Background writer for `events.jsonl`. `log` is fire-and-forget over an
99/// mpsc channel; the writer thread holds `write_lock` while appending so
100/// `clear_log` can take the same lock to safely truncate without racing an
101/// in-flight write.
102#[derive(Clone)]
103pub struct Logger {
104    tx: mpsc::UnboundedSender<LoggedEvent>,
105    write_lock: Arc<std::sync::Mutex<()>>,
106}
107
108impl Logger {
109    pub fn spawn(path: PathBuf) -> Self {
110        let (tx, mut rx) = mpsc::unbounded_channel::<LoggedEvent>();
111        let write_lock = Arc::new(std::sync::Mutex::new(()));
112        let lock_for_thread = write_lock.clone();
113        std::thread::spawn(move || {
114            while let Some(ev) = rx.blocking_recv() {
115                let _guard = lock_for_thread.lock().unwrap_or_else(|p| p.into_inner());
116                if let Err(e) = append_one(&path, &ev) {
117                    error!("stats: failed to append event to {}: {e}", path.display());
118                }
119            }
120        });
121        Self { tx, write_lock }
122    }
123
124    pub fn log(&self, event: EventPayload) {
125        let _ = self.tx.send(LoggedEvent::now(event));
126    }
127
128    /// Shared lock the writer holds across each append. Take it before
129    /// touching `events.jsonl` from outside the writer thread.
130    pub fn write_lock(&self) -> &Arc<std::sync::Mutex<()>> {
131        &self.write_lock
132    }
133}
134
135fn append_one(path: &Path, event: &LoggedEvent) -> std::io::Result<()> {
136    use std::io::Write;
137    if let Some(parent) = path.parent() {
138        std::fs::create_dir_all(parent)?;
139    }
140    let mut opts = std::fs::OpenOptions::new();
141    opts.create(true).append(true);
142    #[cfg(unix)]
143    {
144        use std::os::unix::fs::OpenOptionsExt;
145        opts.mode(0o600);
146    }
147    let mut file = opts.open(path)?;
148    let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
149    line.push('\n');
150    file.write_all(line.as_bytes())?;
151    Ok(())
152}
153
154pub fn read_all(path: &Path) -> Vec<LoggedEvent> {
155    let Ok(content) = std::fs::read_to_string(path) else {
156        return Vec::new();
157    };
158    content
159        .lines()
160        .filter_map(|l| {
161            let l = l.trim();
162            if l.is_empty() {
163                None
164            } else {
165                serde_json::from_str::<LoggedEvent>(l).ok()
166            }
167        })
168        .collect()
169}
170
171#[derive(Debug, Clone, Serialize)]
172pub struct SuppressionCount {
173    pub reason: String,
174    pub label: String,
175    pub count: u32,
176}
177
178#[derive(Debug, Clone, Serialize)]
179pub struct DayBucket {
180    pub date: String,
181    pub taken: u32,
182    pub dismissed: u32,
183}
184
185#[derive(Debug, Clone, Serialize)]
186pub struct Digest {
187    pub range: String,
188    pub range_start: String,
189    pub range_end: String,
190    pub micro_taken: u32,
191    pub micro_dismissed: u32,
192    pub long_taken: u32,
193    pub long_dismissed: u32,
194    pub sleep_shown: u32,
195    pub postponed_total: u32,
196    pub skipped_total: u32,
197    pub suppressions: Vec<SuppressionCount>,
198    pub pause_total_secs: u64,
199    pub pause_count: u32,
200    pub by_hour: Vec<u32>,
201    pub by_day: Vec<DayBucket>,
202}
203
204pub fn compute_digest(events: &[LoggedEvent], range: &str, now: DateTime<Local>) -> Digest {
205    let days_back: i64 = match range {
206        "month" => 30,
207        _ => 7,
208    };
209    let range_start = now - Duration::days(days_back);
210
211    let mut micro_taken = 0u32;
212    let mut micro_dismissed = 0u32;
213    let mut long_taken = 0u32;
214    let mut long_dismissed = 0u32;
215    let mut sleep_shown = 0u32;
216    let mut postponed_total = 0u32;
217    let mut skipped_total = 0u32;
218    let mut pause_total_secs: u64 = 0;
219    let mut pause_count = 0u32;
220    let mut by_hour = vec![0u32; 24];
221    let mut sup_map: HashMap<GuardReason, u32> = HashMap::new();
222    let mut open_pause: Option<DateTime<Utc>> = None;
223
224    for e in events {
225        let local = e.t.with_timezone(&Local);
226        if local < range_start || local > now {
227            continue;
228        }
229        match &e.event {
230            EventPayload::BreakEnd { kind, outcome } => {
231                match (*kind, *outcome) {
232                    (BreakKind::Micro, Outcome::Completed) => micro_taken += 1,
233                    (BreakKind::Micro, Outcome::Dismissed) => micro_dismissed += 1,
234                    (BreakKind::Long, Outcome::Completed) => long_taken += 1,
235                    (BreakKind::Long, Outcome::Dismissed) => long_dismissed += 1,
236                    (BreakKind::Sleep, _) => sleep_shown += 1,
237                }
238                if matches!(outcome, Outcome::Completed) {
239                    let h = local.hour() as usize;
240                    by_hour[h] += 1;
241                }
242            }
243            EventPayload::BreakPostponed { .. } => postponed_total += 1,
244            EventPayload::BreakSkipped { .. } => skipped_total += 1,
245            EventPayload::BreakResumed { .. } => {}
246            EventPayload::GuardSuppress { reason, .. } => {
247                *sup_map.entry(*reason).or_insert(0) += 1;
248            }
249            EventPayload::PauseStart { .. } => {
250                open_pause = Some(e.t);
251            }
252            EventPayload::PauseEnd => {
253                if let Some(ps) = open_pause.take() {
254                    let dur = (e.t - ps).num_seconds().max(0) as u64;
255                    pause_total_secs += dur;
256                    pause_count += 1;
257                }
258            }
259            EventPayload::BreakStart { .. } => {}
260        }
261    }
262
263    let mut suppressions: Vec<SuppressionCount> = sup_map
264        .into_iter()
265        .map(|(reason, count)| SuppressionCount {
266            reason: format!("{reason:?}").to_lowercase(),
267            label: reason.label().to_string(),
268            count,
269        })
270        .collect();
271    suppressions.sort_by_key(|s| std::cmp::Reverse(s.count));
272
273    let heatmap_days = 84i64;
274    let heatmap_start = (now - Duration::days(heatmap_days - 1)).date_naive();
275    let today = now.date_naive();
276    let mut buckets: HashMap<NaiveDate, (u32, u32)> = HashMap::new();
277    for i in 0..heatmap_days {
278        let d = heatmap_start + Duration::days(i);
279        buckets.insert(d, (0, 0));
280    }
281    for e in events {
282        let local = e.t.with_timezone(&Local);
283        let date = local.date_naive();
284        if date < heatmap_start || date > today {
285            continue;
286        }
287        if let EventPayload::BreakEnd { outcome, .. } = e.event {
288            if let Some(b) = buckets.get_mut(&date) {
289                match outcome {
290                    Outcome::Completed => b.0 += 1,
291                    Outcome::Dismissed => b.1 += 1,
292                }
293            }
294        }
295    }
296    let mut by_day: Vec<(NaiveDate, (u32, u32))> = buckets.into_iter().collect();
297    by_day.sort_by_key(|a| a.0);
298    let by_day = by_day
299        .into_iter()
300        .map(|(d, (taken, dismissed))| DayBucket {
301            date: d.format("%Y-%m-%d").to_string(),
302            taken,
303            dismissed,
304        })
305        .collect();
306
307    Digest {
308        range: range.to_string(),
309        range_start: range_start.to_rfc3339(),
310        range_end: now.to_rfc3339(),
311        micro_taken,
312        micro_dismissed,
313        long_taken,
314        long_dismissed,
315        sleep_shown,
316        postponed_total,
317        skipped_total,
318        suppressions,
319        pause_total_secs,
320        pause_count,
321        by_hour,
322        by_day,
323    }
324}
325
326type CsvFields<'a> = (
327    &'a str,
328    Option<&'a str>,
329    Option<&'a str>,
330    Option<&'a str>,
331    Option<String>,
332    Option<String>,
333);
334
335pub fn export_csv(events: &[LoggedEvent]) -> String {
336    let mut out = String::from("timestamp,type,kind,outcome,reason,duration_secs,minutes\n");
337    for e in events {
338        let t = e.t.to_rfc3339();
339        let (typ, kind, outcome, reason, dur, min): CsvFields = match &e.event {
340            EventPayload::BreakStart {
341                kind,
342                duration_secs,
343                ..
344            } => (
345                "break_start",
346                Some(kind_str(*kind)),
347                None,
348                None,
349                Some(duration_secs.to_string()),
350                None,
351            ),
352            EventPayload::BreakEnd { kind, outcome } => (
353                "break_end",
354                Some(kind_str(*kind)),
355                Some(outcome_str(*outcome)),
356                None,
357                None,
358                None,
359            ),
360            EventPayload::BreakPostponed { kind, minutes } => (
361                "break_postponed",
362                Some(kind_str(*kind)),
363                None,
364                None,
365                None,
366                Some(minutes.to_string()),
367            ),
368            EventPayload::BreakSkipped { kind, .. } => (
369                "break_skipped",
370                Some(kind_str(*kind)),
371                None,
372                None,
373                None,
374                None,
375            ),
376            EventPayload::BreakResumed { kind } => (
377                "break_resumed",
378                Some(kind_str(*kind)),
379                None,
380                None,
381                None,
382                None,
383            ),
384            EventPayload::PauseStart { duration_secs } => (
385                "pause_start",
386                None,
387                None,
388                None,
389                duration_secs.map(|d| d.to_string()),
390                None,
391            ),
392            EventPayload::PauseEnd => ("pause_end", None, None, None, None, None),
393            EventPayload::GuardSuppress { kind, reason } => (
394                "guard_suppress",
395                Some(kind_str(*kind)),
396                None,
397                Some(guard_str(*reason)),
398                None,
399                None,
400            ),
401        };
402        out.push_str(&format!(
403            "{},{},{},{},{},{},{}\n",
404            t,
405            typ,
406            kind.unwrap_or(""),
407            outcome.unwrap_or(""),
408            reason.unwrap_or(""),
409            dur.unwrap_or_default(),
410            min.unwrap_or_default(),
411        ));
412    }
413    out
414}
415
416fn kind_str(k: BreakKind) -> &'static str {
417    match k {
418        BreakKind::Micro => "micro",
419        BreakKind::Long => "long",
420        BreakKind::Sleep => "sleep",
421    }
422}
423
424fn outcome_str(o: Outcome) -> &'static str {
425    match o {
426        Outcome::Completed => "completed",
427        Outcome::Dismissed => "dismissed",
428    }
429}
430
431fn guard_str(g: GuardReason) -> &'static str {
432    match g {
433        GuardReason::Dnd => "dnd",
434        GuardReason::Camera => "camera",
435        GuardReason::Idle => "idle",
436        GuardReason::AppPause => "app_pause",
437        GuardReason::Typing => "typing",
438        GuardReason::Video => "video",
439    }
440}
441
442/// Remove `events.jsonl`. Takes the shared writer lock so an in-flight
443/// append from the [`Logger`] worker thread can finish first — without it,
444/// the writer could re-create the file between our `remove_file` and the
445/// next event landing.
446pub fn clear_log(path: &Path, write_lock: &std::sync::Mutex<()>) -> std::io::Result<()> {
447    let _guard = write_lock.lock().unwrap_or_else(|p| p.into_inner());
448    if path.exists() {
449        std::fs::remove_file(path)?;
450    }
451    Ok(())
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use chrono::TimeZone;
458
459    fn ev(at: DateTime<Local>, payload: EventPayload) -> LoggedEvent {
460        LoggedEvent {
461            t: at.with_timezone(&Utc),
462            event: payload,
463        }
464    }
465
466    fn now() -> DateTime<Local> {
467        Local.with_ymd_and_hms(2026, 5, 14, 14, 0, 0).unwrap()
468    }
469
470    #[test]
471    fn empty_digest_has_zero_totals() {
472        let d = compute_digest(&[], "week", now());
473        assert_eq!(d.micro_taken, 0);
474        assert_eq!(d.long_taken, 0);
475        assert_eq!(d.sleep_shown, 0);
476        assert_eq!(d.by_day.len(), 84);
477        assert_eq!(d.by_hour.len(), 24);
478    }
479
480    #[test]
481    fn counts_break_end_completed_vs_dismissed() {
482        let n = now();
483        let events = vec![
484            ev(
485                n - Duration::hours(2),
486                EventPayload::BreakEnd {
487                    kind: BreakKind::Micro,
488                    outcome: Outcome::Completed,
489                },
490            ),
491            ev(
492                n - Duration::hours(1),
493                EventPayload::BreakEnd {
494                    kind: BreakKind::Micro,
495                    outcome: Outcome::Dismissed,
496                },
497            ),
498            ev(
499                n - Duration::days(3),
500                EventPayload::BreakEnd {
501                    kind: BreakKind::Long,
502                    outcome: Outcome::Completed,
503                },
504            ),
505            ev(
506                n - Duration::days(2),
507                EventPayload::BreakEnd {
508                    kind: BreakKind::Sleep,
509                    outcome: Outcome::Completed,
510                },
511            ),
512        ];
513        let d = compute_digest(&events, "week", n);
514        assert_eq!(d.micro_taken, 1);
515        assert_eq!(d.micro_dismissed, 1);
516        assert_eq!(d.long_taken, 1);
517        assert_eq!(d.sleep_shown, 1);
518    }
519
520    #[test]
521    fn week_range_excludes_older_events() {
522        let n = now();
523        let events = vec![
524            ev(
525                n - Duration::days(2),
526                EventPayload::BreakEnd {
527                    kind: BreakKind::Micro,
528                    outcome: Outcome::Completed,
529                },
530            ),
531            ev(
532                n - Duration::days(20),
533                EventPayload::BreakEnd {
534                    kind: BreakKind::Micro,
535                    outcome: Outcome::Completed,
536                },
537            ),
538        ];
539        let d_week = compute_digest(&events, "week", n);
540        assert_eq!(d_week.micro_taken, 1);
541        let d_month = compute_digest(&events, "month", n);
542        assert_eq!(d_month.micro_taken, 2);
543    }
544
545    #[test]
546    fn suppressions_sorted_by_count_desc() {
547        let n = now();
548        let events = vec![
549            ev(
550                n - Duration::hours(1),
551                EventPayload::GuardSuppress {
552                    kind: BreakKind::Micro,
553                    reason: GuardReason::Camera,
554                },
555            ),
556            ev(
557                n - Duration::hours(2),
558                EventPayload::GuardSuppress {
559                    kind: BreakKind::Micro,
560                    reason: GuardReason::Camera,
561                },
562            ),
563            ev(
564                n - Duration::hours(3),
565                EventPayload::GuardSuppress {
566                    kind: BreakKind::Long,
567                    reason: GuardReason::Dnd,
568                },
569            ),
570        ];
571        let d = compute_digest(&events, "week", n);
572        assert_eq!(d.suppressions.len(), 2);
573        assert_eq!(d.suppressions[0].reason, "camera");
574        assert_eq!(d.suppressions[0].count, 2);
575        assert_eq!(d.suppressions[1].reason, "dnd");
576        assert_eq!(d.suppressions[1].count, 1);
577    }
578
579    #[test]
580    fn pause_pairs_start_and_end() {
581        let n = now();
582        let events = vec![
583            ev(
584                n - Duration::hours(2),
585                EventPayload::PauseStart {
586                    duration_secs: Some(3600),
587                },
588            ),
589            ev(n - Duration::hours(1), EventPayload::PauseEnd),
590            ev(
591                n - Duration::minutes(30),
592                EventPayload::PauseStart {
593                    duration_secs: None,
594                },
595            ),
596            ev(n - Duration::minutes(15), EventPayload::PauseEnd),
597        ];
598        let d = compute_digest(&events, "week", n);
599        assert_eq!(d.pause_count, 2);
600        assert_eq!(d.pause_total_secs, 3600 + 15 * 60);
601    }
602
603    #[test]
604    fn by_hour_buckets_completed_breaks() {
605        let n = now();
606        let nine_am = Local.with_ymd_and_hms(2026, 5, 14, 9, 30, 0).unwrap();
607        let events = vec![
608            ev(
609                nine_am,
610                EventPayload::BreakEnd {
611                    kind: BreakKind::Micro,
612                    outcome: Outcome::Completed,
613                },
614            ),
615            ev(
616                nine_am + Duration::minutes(5),
617                EventPayload::BreakEnd {
618                    kind: BreakKind::Micro,
619                    outcome: Outcome::Completed,
620                },
621            ),
622            ev(
623                nine_am,
624                EventPayload::BreakEnd {
625                    kind: BreakKind::Micro,
626                    outcome: Outcome::Dismissed,
627                },
628            ),
629        ];
630        let d = compute_digest(&events, "week", n);
631        assert_eq!(d.by_hour[9], 2);
632        assert_eq!(d.by_hour[8], 0);
633    }
634
635    #[test]
636    fn heatmap_always_has_84_days_in_order() {
637        let n = now();
638        let d = compute_digest(&[], "week", n);
639        assert_eq!(d.by_day.len(), 84);
640        for window in d.by_day.windows(2) {
641            assert!(window[0].date < window[1].date);
642        }
643        assert_eq!(
644            d.by_day.last().unwrap().date,
645            n.format("%Y-%m-%d").to_string()
646        );
647    }
648
649    #[test]
650    fn csv_export_has_header_and_rows() {
651        let n = now();
652        let events = vec![
653            ev(
654                n,
655                EventPayload::BreakEnd {
656                    kind: BreakKind::Micro,
657                    outcome: Outcome::Completed,
658                },
659            ),
660            ev(
661                n,
662                EventPayload::GuardSuppress {
663                    kind: BreakKind::Long,
664                    reason: GuardReason::Dnd,
665                },
666            ),
667        ];
668        let csv = export_csv(&events);
669        let lines: Vec<&str> = csv.lines().collect();
670        assert!(lines[0].starts_with("timestamp,type"));
671        assert!(lines[1].contains("break_end"));
672        assert!(lines[1].contains("micro"));
673        assert!(lines[1].contains("completed"));
674        assert!(lines[2].contains("guard_suppress"));
675        assert!(lines[2].contains("dnd"));
676    }
677
678    #[test]
679    fn round_trip_event_through_json() {
680        let n = now();
681        let original = ev(
682            n,
683            EventPayload::BreakStart {
684                kind: BreakKind::Long,
685                duration_secs: 600,
686                enforceable: true,
687            },
688        );
689        let json = serde_json::to_string(&original).unwrap();
690        assert!(json.contains("\"type\":\"break_start\""));
691        assert!(json.contains("\"kind\":\"long\""));
692        let parsed: LoggedEvent = serde_json::from_str(&json).unwrap();
693        match parsed.event {
694            EventPayload::BreakStart {
695                duration_secs,
696                enforceable,
697                ..
698            } => {
699                assert_eq!(duration_secs, 600);
700                assert!(enforceable);
701            }
702            _ => panic!("wrong variant"),
703        }
704    }
705
706    #[test]
707    fn read_all_skips_blank_and_corrupt_lines() {
708        let dir = crate::test_support::temp_dir();
709        let path = dir.path().join("events.jsonl");
710        let valid = serde_json::to_string(&ev(
711            now(),
712            EventPayload::BreakEnd {
713                kind: BreakKind::Micro,
714                outcome: Outcome::Completed,
715            },
716        ))
717        .unwrap();
718        let body = format!("\n{valid}\nnot json\n\n{valid}\n");
719        std::fs::write(&path, body).unwrap();
720        let events = read_all(&path);
721        assert_eq!(events.len(), 2);
722    }
723
724    #[test]
725    fn read_all_returns_empty_when_missing() {
726        let path = PathBuf::from("/tmp/entracte-definitely-does-not-exist.jsonl");
727        let events = read_all(&path);
728        assert!(events.is_empty());
729    }
730
731    #[test]
732    fn typing_guard_reason_round_trips() {
733        let n = now();
734        let original = ev(
735            n,
736            EventPayload::GuardSuppress {
737                kind: BreakKind::Micro,
738                reason: GuardReason::Typing,
739            },
740        );
741        let json = serde_json::to_string(&original).unwrap();
742        assert!(json.contains("\"reason\":\"typing\""));
743        let parsed: LoggedEvent = serde_json::from_str(&json).unwrap();
744        match parsed.event {
745            EventPayload::GuardSuppress { reason, .. } => {
746                assert_eq!(reason, GuardReason::Typing);
747            }
748            _ => panic!("wrong variant"),
749        }
750    }
751
752    #[test]
753    fn typing_suppression_counts_into_digest() {
754        let n = now();
755        let events = vec![
756            ev(
757                n - Duration::hours(1),
758                EventPayload::GuardSuppress {
759                    kind: BreakKind::Long,
760                    reason: GuardReason::Typing,
761                },
762            ),
763            ev(
764                n - Duration::hours(2),
765                EventPayload::GuardSuppress {
766                    kind: BreakKind::Micro,
767                    reason: GuardReason::Typing,
768                },
769            ),
770        ];
771        let d = compute_digest(&events, "week", n);
772        let typing = d.suppressions.iter().find(|s| s.reason == "typing");
773        let typing = typing.expect("typing suppression present");
774        assert_eq!(typing.count, 2);
775        assert_eq!(typing.label, "Actively typing");
776    }
777
778    #[test]
779    fn typing_guard_reason_csv_uses_snake_case() {
780        let n = now();
781        let events = vec![ev(
782            n,
783            EventPayload::GuardSuppress {
784                kind: BreakKind::Long,
785                reason: GuardReason::Typing,
786            },
787        )];
788        let csv = export_csv(&events);
789        assert!(csv.lines().nth(1).unwrap().contains("typing"));
790    }
791}