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, Datelike, Duration, Local, NaiveDate, Timelike, Utc, Weekday};
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    Plugin,
35}
36
37impl GuardReason {
38    fn label(self) -> &'static str {
39        match self {
40            GuardReason::Dnd => "Do Not Disturb",
41            GuardReason::Camera => "Camera in use",
42            GuardReason::Idle => "Idle",
43            GuardReason::AppPause => "Paused-app running",
44            GuardReason::Typing => "Actively typing",
45            GuardReason::Video => "Video playing",
46            GuardReason::Plugin => "Plugin detector",
47        }
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(tag = "type", rename_all = "snake_case")]
53pub enum EventPayload {
54    BreakStart {
55        kind: BreakKind,
56        duration_secs: u64,
57        enforceable: bool,
58    },
59    BreakEnd {
60        kind: BreakKind,
61        outcome: Outcome,
62    },
63    BreakPostponed {
64        kind: BreakKind,
65        minutes: u32,
66    },
67    BreakSkipped {
68        kind: BreakKind,
69        source: SkipSource,
70    },
71    BreakResumed {
72        kind: BreakKind,
73    },
74    PauseStart {
75        duration_secs: Option<u64>,
76    },
77    PauseEnd,
78    GuardSuppress {
79        kind: BreakKind,
80        reason: GuardReason,
81    },
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct LoggedEvent {
86    pub t: DateTime<Utc>,
87    #[serde(flatten)]
88    pub event: EventPayload,
89}
90
91impl LoggedEvent {
92    pub fn now(event: EventPayload) -> Self {
93        Self {
94            t: Utc::now(),
95            event,
96        }
97    }
98}
99
100/// Background writer for `events.jsonl`. `log` is fire-and-forget over an
101/// mpsc channel; the writer thread holds `write_lock` while appending so
102/// `clear_log` can take the same lock to safely truncate without racing an
103/// in-flight write.
104#[derive(Clone)]
105pub struct Logger {
106    tx: mpsc::UnboundedSender<LoggedEvent>,
107    write_lock: Arc<std::sync::Mutex<()>>,
108}
109
110impl Logger {
111    pub fn spawn(path: PathBuf) -> Self {
112        let (tx, mut rx) = mpsc::unbounded_channel::<LoggedEvent>();
113        let write_lock = Arc::new(std::sync::Mutex::new(()));
114        let lock_for_thread = write_lock.clone();
115        std::thread::spawn(move || {
116            while let Some(ev) = rx.blocking_recv() {
117                let _guard = lock_for_thread.lock().unwrap_or_else(|p| p.into_inner());
118                if let Err(e) = append_one(&path, &ev) {
119                    error!("stats: failed to append event to {}: {e}", path.display());
120                }
121            }
122        });
123        Self { tx, write_lock }
124    }
125
126    pub fn log(&self, event: EventPayload) {
127        let _ = self.tx.send(LoggedEvent::now(event));
128    }
129
130    /// Shared lock the writer holds across each append. Take it before
131    /// touching `events.jsonl` from outside the writer thread.
132    pub fn write_lock(&self) -> &Arc<std::sync::Mutex<()>> {
133        &self.write_lock
134    }
135}
136
137fn append_one(path: &Path, event: &LoggedEvent) -> std::io::Result<()> {
138    use std::io::Write;
139    if let Some(parent) = path.parent() {
140        std::fs::create_dir_all(parent)?;
141    }
142    let mut opts = std::fs::OpenOptions::new();
143    opts.create(true).append(true);
144    #[cfg(unix)]
145    {
146        use std::os::unix::fs::OpenOptionsExt;
147        opts.mode(0o600);
148    }
149    let mut file = opts.open(path)?;
150    let mut line = serde_json::to_string(event).map_err(std::io::Error::other)?;
151    line.push('\n');
152    file.write_all(line.as_bytes())?;
153    Ok(())
154}
155
156pub fn read_all(path: &Path) -> Vec<LoggedEvent> {
157    let Ok(content) = std::fs::read_to_string(path) else {
158        return Vec::new();
159    };
160    content
161        .lines()
162        .filter_map(|l| {
163            let l = l.trim();
164            if l.is_empty() {
165                None
166            } else {
167                serde_json::from_str::<LoggedEvent>(l).ok()
168            }
169        })
170        .collect()
171}
172
173#[derive(Debug, Clone, Serialize)]
174pub struct SuppressionCount {
175    pub reason: String,
176    pub label: String,
177    pub count: u32,
178}
179
180#[derive(Debug, Clone, Serialize)]
181pub struct SuppressionByKind {
182    pub kind: String,
183    pub reason: String,
184    pub label: String,
185    pub count: u32,
186}
187
188#[derive(Debug, Clone, Serialize)]
189pub struct DayBucket {
190    pub date: String,
191    pub taken: u32,
192    pub dismissed: u32,
193}
194
195#[derive(Debug, Clone, Serialize)]
196pub struct WeekdayBucket {
197    pub weekday: u8,
198    pub taken: u32,
199    pub dismissed: u32,
200}
201
202#[derive(Debug, Clone, Serialize, Default)]
203pub struct PreviousPeriod {
204    pub breaks_taken: u32,
205    pub breaks_dismissed: u32,
206    pub postponed_total: u32,
207    pub skipped_total: u32,
208}
209
210#[derive(Debug, Clone, Serialize, Default)]
211pub struct PostponeFollowThrough {
212    pub total: u32,
213    pub taken: u32,
214    pub dismissed: u32,
215    pub skipped: u32,
216    pub unresolved: u32,
217}
218
219#[derive(Debug, Clone, Serialize)]
220pub struct Digest {
221    pub range: String,
222    pub range_start: String,
223    pub range_end: String,
224    pub micro_taken: u32,
225    pub micro_dismissed: u32,
226    pub long_taken: u32,
227    pub long_dismissed: u32,
228    pub sleep_shown: u32,
229    pub postponed_total: u32,
230    pub skipped_total: u32,
231    pub suppressions: Vec<SuppressionCount>,
232    pub suppressions_by_kind: Vec<SuppressionByKind>,
233    pub pause_total_secs: u64,
234    pub pause_count: u32,
235    pub by_hour: Vec<u32>,
236    pub by_day: Vec<DayBucket>,
237    pub by_weekday: Vec<WeekdayBucket>,
238    pub previous: PreviousPeriod,
239    pub postpone_follow_through: PostponeFollowThrough,
240}
241
242fn weekday_index(d: Weekday) -> u8 {
243    d.num_days_from_monday() as u8
244}
245
246pub fn compute_digest(events: &[LoggedEvent], range: &str, now: DateTime<Local>) -> Digest {
247    let days_back: i64 = match range {
248        "month" => 30,
249        _ => 7,
250    };
251    let range_start = now - Duration::days(days_back);
252    let prev_range_start = now - Duration::days(days_back * 2);
253
254    let mut micro_taken = 0u32;
255    let mut micro_dismissed = 0u32;
256    let mut long_taken = 0u32;
257    let mut long_dismissed = 0u32;
258    let mut sleep_shown = 0u32;
259    let mut postponed_total = 0u32;
260    let mut skipped_total = 0u32;
261    let mut pause_total_secs: u64 = 0;
262    let mut pause_count = 0u32;
263    let mut by_hour = vec![0u32; 24];
264    let mut by_weekday_taken = [0u32; 7];
265    let mut by_weekday_dismissed = [0u32; 7];
266    let mut sup_map: HashMap<GuardReason, u32> = HashMap::new();
267    let mut sup_kind_map: HashMap<(BreakKind, GuardReason), u32> = HashMap::new();
268    let mut previous = PreviousPeriod::default();
269    let mut open_pause: Option<DateTime<Utc>> = None;
270
271    for e in events {
272        let local = e.t.with_timezone(&Local);
273        let in_range = local >= range_start && local <= now;
274        let in_prev = local >= prev_range_start && local < range_start;
275        if !in_range && !in_prev {
276            continue;
277        }
278        if in_prev {
279            match &e.event {
280                EventPayload::BreakEnd { kind, outcome } => match (*kind, *outcome) {
281                    (BreakKind::Micro | BreakKind::Long, Outcome::Completed) => {
282                        previous.breaks_taken += 1
283                    }
284                    (BreakKind::Micro | BreakKind::Long, Outcome::Dismissed) => {
285                        previous.breaks_dismissed += 1
286                    }
287                    (BreakKind::Sleep, _) => {}
288                },
289                EventPayload::BreakPostponed { .. } => previous.postponed_total += 1,
290                EventPayload::BreakSkipped { .. } => previous.skipped_total += 1,
291                _ => {}
292            }
293            continue;
294        }
295        match &e.event {
296            EventPayload::BreakEnd { kind, outcome } => {
297                match (*kind, *outcome) {
298                    (BreakKind::Micro, Outcome::Completed) => micro_taken += 1,
299                    (BreakKind::Micro, Outcome::Dismissed) => micro_dismissed += 1,
300                    (BreakKind::Long, Outcome::Completed) => long_taken += 1,
301                    (BreakKind::Long, Outcome::Dismissed) => long_dismissed += 1,
302                    (BreakKind::Sleep, _) => sleep_shown += 1,
303                }
304                let wd = weekday_index(local.weekday()) as usize;
305                match (*kind, *outcome) {
306                    (BreakKind::Micro | BreakKind::Long, Outcome::Completed) => {
307                        by_weekday_taken[wd] += 1;
308                        let h = local.hour() as usize;
309                        by_hour[h] += 1;
310                    }
311                    (BreakKind::Micro | BreakKind::Long, Outcome::Dismissed) => {
312                        by_weekday_dismissed[wd] += 1;
313                    }
314                    (BreakKind::Sleep, _) => {}
315                }
316            }
317            EventPayload::BreakPostponed { .. } => postponed_total += 1,
318            EventPayload::BreakSkipped { .. } => skipped_total += 1,
319            EventPayload::BreakResumed { .. } => {}
320            EventPayload::GuardSuppress { kind, reason } => {
321                *sup_map.entry(*reason).or_insert(0) += 1;
322                *sup_kind_map.entry((*kind, *reason)).or_insert(0) += 1;
323            }
324            EventPayload::PauseStart { .. } => {
325                open_pause = Some(e.t);
326            }
327            EventPayload::PauseEnd => {
328                if let Some(ps) = open_pause.take() {
329                    let dur = (e.t - ps).num_seconds().max(0) as u64;
330                    pause_total_secs += dur;
331                    pause_count += 1;
332                }
333            }
334            EventPayload::BreakStart { .. } => {}
335        }
336    }
337
338    let mut suppressions: Vec<SuppressionCount> = sup_map
339        .into_iter()
340        .map(|(reason, count)| SuppressionCount {
341            reason: format!("{reason:?}").to_lowercase(),
342            label: reason.label().to_string(),
343            count,
344        })
345        .collect();
346    suppressions.sort_by_key(|s| std::cmp::Reverse(s.count));
347
348    let mut suppressions_by_kind: Vec<SuppressionByKind> = sup_kind_map
349        .into_iter()
350        .map(|((kind, reason), count)| SuppressionByKind {
351            kind: kind_str(kind).to_string(),
352            reason: format!("{reason:?}").to_lowercase(),
353            label: reason.label().to_string(),
354            count,
355        })
356        .collect();
357    suppressions_by_kind.sort_by(|a, b| {
358        b.count
359            .cmp(&a.count)
360            .then_with(|| a.kind.cmp(&b.kind))
361            .then_with(|| a.reason.cmp(&b.reason))
362    });
363
364    let by_weekday: Vec<WeekdayBucket> = (0u8..7)
365        .map(|w| WeekdayBucket {
366            weekday: w,
367            taken: by_weekday_taken[w as usize],
368            dismissed: by_weekday_dismissed[w as usize],
369        })
370        .collect();
371
372    let postpone_follow_through = compute_postpone_follow_through(events, range_start, now);
373
374    let heatmap_days = 84i64;
375    let heatmap_start = (now - Duration::days(heatmap_days - 1)).date_naive();
376    let today = now.date_naive();
377    let mut buckets: HashMap<NaiveDate, (u32, u32)> = HashMap::new();
378    for i in 0..heatmap_days {
379        let d = heatmap_start + Duration::days(i);
380        buckets.insert(d, (0, 0));
381    }
382    for e in events {
383        let local = e.t.with_timezone(&Local);
384        let date = local.date_naive();
385        if date < heatmap_start || date > today {
386            continue;
387        }
388        if let EventPayload::BreakEnd { outcome, .. } = e.event {
389            if let Some(b) = buckets.get_mut(&date) {
390                match outcome {
391                    Outcome::Completed => b.0 += 1,
392                    Outcome::Dismissed => b.1 += 1,
393                }
394            }
395        }
396    }
397    let mut by_day: Vec<(NaiveDate, (u32, u32))> = buckets.into_iter().collect();
398    by_day.sort_by_key(|a| a.0);
399    let by_day = by_day
400        .into_iter()
401        .map(|(d, (taken, dismissed))| DayBucket {
402            date: d.format("%Y-%m-%d").to_string(),
403            taken,
404            dismissed,
405        })
406        .collect();
407
408    Digest {
409        range: range.to_string(),
410        range_start: range_start.to_rfc3339(),
411        range_end: now.to_rfc3339(),
412        micro_taken,
413        micro_dismissed,
414        long_taken,
415        long_dismissed,
416        sleep_shown,
417        postponed_total,
418        skipped_total,
419        suppressions,
420        suppressions_by_kind,
421        pause_total_secs,
422        pause_count,
423        by_hour,
424        by_day,
425        by_weekday,
426        previous,
427        postpone_follow_through,
428    }
429}
430
431/// For every `BreakPostponed` event inside `[range_start, now]`, look
432/// forward in the (chronologically ordered) event stream for the next
433/// `BreakEnd` or `BreakSkipped` of the same kind and bucket the outcome.
434/// Intervening postpones of the same kind don't resolve — we keep
435/// scanning. A postpone with no later resolution in the log counts as
436/// `unresolved`.
437fn compute_postpone_follow_through(
438    events: &[LoggedEvent],
439    range_start: DateTime<Local>,
440    now: DateTime<Local>,
441) -> PostponeFollowThrough {
442    let mut out = PostponeFollowThrough::default();
443    for (i, e) in events.iter().enumerate() {
444        let EventPayload::BreakPostponed { kind, .. } = &e.event else {
445            continue;
446        };
447        let local = e.t.with_timezone(&Local);
448        if local < range_start || local > now {
449            continue;
450        }
451        out.total += 1;
452        let mut resolved = false;
453        for f in &events[i + 1..] {
454            match &f.event {
455                EventPayload::BreakEnd { kind: k2, outcome } if k2 == kind => {
456                    match outcome {
457                        Outcome::Completed => out.taken += 1,
458                        Outcome::Dismissed => out.dismissed += 1,
459                    }
460                    resolved = true;
461                    break;
462                }
463                EventPayload::BreakSkipped { kind: k2, .. } if k2 == kind => {
464                    out.skipped += 1;
465                    resolved = true;
466                    break;
467                }
468                _ => {}
469            }
470        }
471        if !resolved {
472            out.unresolved += 1;
473        }
474    }
475    out
476}
477
478type CsvFields<'a> = (
479    &'a str,
480    Option<&'a str>,
481    Option<&'a str>,
482    Option<&'a str>,
483    Option<String>,
484    Option<String>,
485);
486
487pub fn export_csv(events: &[LoggedEvent]) -> String {
488    let mut out = String::from("timestamp,type,kind,outcome,reason,duration_secs,minutes\n");
489    for e in events {
490        let t = e.t.to_rfc3339();
491        let (typ, kind, outcome, reason, dur, min): CsvFields = match &e.event {
492            EventPayload::BreakStart {
493                kind,
494                duration_secs,
495                ..
496            } => (
497                "break_start",
498                Some(kind_str(*kind)),
499                None,
500                None,
501                Some(duration_secs.to_string()),
502                None,
503            ),
504            EventPayload::BreakEnd { kind, outcome } => (
505                "break_end",
506                Some(kind_str(*kind)),
507                Some(outcome_str(*outcome)),
508                None,
509                None,
510                None,
511            ),
512            EventPayload::BreakPostponed { kind, minutes } => (
513                "break_postponed",
514                Some(kind_str(*kind)),
515                None,
516                None,
517                None,
518                Some(minutes.to_string()),
519            ),
520            EventPayload::BreakSkipped { kind, .. } => (
521                "break_skipped",
522                Some(kind_str(*kind)),
523                None,
524                None,
525                None,
526                None,
527            ),
528            EventPayload::BreakResumed { kind } => (
529                "break_resumed",
530                Some(kind_str(*kind)),
531                None,
532                None,
533                None,
534                None,
535            ),
536            EventPayload::PauseStart { duration_secs } => (
537                "pause_start",
538                None,
539                None,
540                None,
541                duration_secs.map(|d| d.to_string()),
542                None,
543            ),
544            EventPayload::PauseEnd => ("pause_end", None, None, None, None, None),
545            EventPayload::GuardSuppress { kind, reason } => (
546                "guard_suppress",
547                Some(kind_str(*kind)),
548                None,
549                Some(guard_str(*reason)),
550                None,
551                None,
552            ),
553        };
554        out.push_str(&format!(
555            "{},{},{},{},{},{},{}\n",
556            t,
557            typ,
558            kind.unwrap_or(""),
559            outcome.unwrap_or(""),
560            reason.unwrap_or(""),
561            dur.unwrap_or_default(),
562            min.unwrap_or_default(),
563        ));
564    }
565    out
566}
567
568fn kind_str(k: BreakKind) -> &'static str {
569    match k {
570        BreakKind::Micro => "micro",
571        BreakKind::Long => "long",
572        BreakKind::Sleep => "sleep",
573    }
574}
575
576fn outcome_str(o: Outcome) -> &'static str {
577    match o {
578        Outcome::Completed => "completed",
579        Outcome::Dismissed => "dismissed",
580    }
581}
582
583fn guard_str(g: GuardReason) -> &'static str {
584    match g {
585        GuardReason::Dnd => "dnd",
586        GuardReason::Camera => "camera",
587        GuardReason::Idle => "idle",
588        GuardReason::AppPause => "app_pause",
589        GuardReason::Typing => "typing",
590        GuardReason::Video => "video",
591        GuardReason::Plugin => "plugin",
592    }
593}
594
595/// Remove `events.jsonl`. Takes the shared writer lock so an in-flight
596/// append from the [`Logger`] worker thread can finish first — without it,
597/// the writer could re-create the file between our `remove_file` and the
598/// next event landing.
599pub fn clear_log(path: &Path, write_lock: &std::sync::Mutex<()>) -> std::io::Result<()> {
600    let _guard = write_lock.lock().unwrap_or_else(|p| p.into_inner());
601    match std::fs::remove_file(path) {
602        Ok(()) => Ok(()),
603        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
604        Err(e) => Err(e),
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use chrono::TimeZone;
612
613    fn ev(at: DateTime<Local>, payload: EventPayload) -> LoggedEvent {
614        LoggedEvent {
615            t: at.with_timezone(&Utc),
616            event: payload,
617        }
618    }
619
620    fn now() -> DateTime<Local> {
621        Local.with_ymd_and_hms(2026, 5, 14, 14, 0, 0).unwrap()
622    }
623
624    #[test]
625    fn empty_digest_has_zero_totals() {
626        let d = compute_digest(&[], "week", now());
627        assert_eq!(d.micro_taken, 0);
628        assert_eq!(d.long_taken, 0);
629        assert_eq!(d.sleep_shown, 0);
630        assert_eq!(d.by_day.len(), 84);
631        assert_eq!(d.by_hour.len(), 24);
632    }
633
634    #[test]
635    fn counts_break_end_completed_vs_dismissed() {
636        let n = now();
637        let events = vec![
638            ev(
639                n - Duration::hours(2),
640                EventPayload::BreakEnd {
641                    kind: BreakKind::Micro,
642                    outcome: Outcome::Completed,
643                },
644            ),
645            ev(
646                n - Duration::hours(1),
647                EventPayload::BreakEnd {
648                    kind: BreakKind::Micro,
649                    outcome: Outcome::Dismissed,
650                },
651            ),
652            ev(
653                n - Duration::days(3),
654                EventPayload::BreakEnd {
655                    kind: BreakKind::Long,
656                    outcome: Outcome::Completed,
657                },
658            ),
659            ev(
660                n - Duration::days(2),
661                EventPayload::BreakEnd {
662                    kind: BreakKind::Sleep,
663                    outcome: Outcome::Completed,
664                },
665            ),
666        ];
667        let d = compute_digest(&events, "week", n);
668        assert_eq!(d.micro_taken, 1);
669        assert_eq!(d.micro_dismissed, 1);
670        assert_eq!(d.long_taken, 1);
671        assert_eq!(d.sleep_shown, 1);
672    }
673
674    #[test]
675    fn week_range_excludes_older_events() {
676        let n = now();
677        let events = vec![
678            ev(
679                n - Duration::days(2),
680                EventPayload::BreakEnd {
681                    kind: BreakKind::Micro,
682                    outcome: Outcome::Completed,
683                },
684            ),
685            ev(
686                n - Duration::days(20),
687                EventPayload::BreakEnd {
688                    kind: BreakKind::Micro,
689                    outcome: Outcome::Completed,
690                },
691            ),
692        ];
693        let d_week = compute_digest(&events, "week", n);
694        assert_eq!(d_week.micro_taken, 1);
695        let d_month = compute_digest(&events, "month", n);
696        assert_eq!(d_month.micro_taken, 2);
697    }
698
699    #[test]
700    fn suppressions_sorted_by_count_desc() {
701        let n = now();
702        let events = vec![
703            ev(
704                n - Duration::hours(1),
705                EventPayload::GuardSuppress {
706                    kind: BreakKind::Micro,
707                    reason: GuardReason::Camera,
708                },
709            ),
710            ev(
711                n - Duration::hours(2),
712                EventPayload::GuardSuppress {
713                    kind: BreakKind::Micro,
714                    reason: GuardReason::Camera,
715                },
716            ),
717            ev(
718                n - Duration::hours(3),
719                EventPayload::GuardSuppress {
720                    kind: BreakKind::Long,
721                    reason: GuardReason::Dnd,
722                },
723            ),
724        ];
725        let d = compute_digest(&events, "week", n);
726        assert_eq!(d.suppressions.len(), 2);
727        assert_eq!(d.suppressions[0].reason, "camera");
728        assert_eq!(d.suppressions[0].count, 2);
729        assert_eq!(d.suppressions[1].reason, "dnd");
730        assert_eq!(d.suppressions[1].count, 1);
731    }
732
733    #[test]
734    fn pause_pairs_start_and_end() {
735        let n = now();
736        let events = vec![
737            ev(
738                n - Duration::hours(2),
739                EventPayload::PauseStart {
740                    duration_secs: Some(3600),
741                },
742            ),
743            ev(n - Duration::hours(1), EventPayload::PauseEnd),
744            ev(
745                n - Duration::minutes(30),
746                EventPayload::PauseStart {
747                    duration_secs: None,
748                },
749            ),
750            ev(n - Duration::minutes(15), EventPayload::PauseEnd),
751        ];
752        let d = compute_digest(&events, "week", n);
753        assert_eq!(d.pause_count, 2);
754        assert_eq!(d.pause_total_secs, 3600 + 15 * 60);
755    }
756
757    #[test]
758    fn by_hour_buckets_completed_breaks() {
759        let n = now();
760        let nine_am = Local.with_ymd_and_hms(2026, 5, 14, 9, 30, 0).unwrap();
761        let events = vec![
762            ev(
763                nine_am,
764                EventPayload::BreakEnd {
765                    kind: BreakKind::Micro,
766                    outcome: Outcome::Completed,
767                },
768            ),
769            ev(
770                nine_am + Duration::minutes(5),
771                EventPayload::BreakEnd {
772                    kind: BreakKind::Micro,
773                    outcome: Outcome::Completed,
774                },
775            ),
776            ev(
777                nine_am,
778                EventPayload::BreakEnd {
779                    kind: BreakKind::Micro,
780                    outcome: Outcome::Dismissed,
781                },
782            ),
783        ];
784        let d = compute_digest(&events, "week", n);
785        assert_eq!(d.by_hour[9], 2);
786        assert_eq!(d.by_hour[8], 0);
787    }
788
789    #[test]
790    fn heatmap_always_has_84_days_in_order() {
791        let n = now();
792        let d = compute_digest(&[], "week", n);
793        assert_eq!(d.by_day.len(), 84);
794        for window in d.by_day.windows(2) {
795            assert!(window[0].date < window[1].date);
796        }
797        assert_eq!(
798            d.by_day.last().unwrap().date,
799            n.format("%Y-%m-%d").to_string()
800        );
801    }
802
803    #[test]
804    fn csv_export_has_header_and_rows() {
805        let n = now();
806        let events = vec![
807            ev(
808                n,
809                EventPayload::BreakEnd {
810                    kind: BreakKind::Micro,
811                    outcome: Outcome::Completed,
812                },
813            ),
814            ev(
815                n,
816                EventPayload::GuardSuppress {
817                    kind: BreakKind::Long,
818                    reason: GuardReason::Dnd,
819                },
820            ),
821        ];
822        let csv = export_csv(&events);
823        let lines: Vec<&str> = csv.lines().collect();
824        assert!(lines[0].starts_with("timestamp,type"));
825        assert!(lines[1].contains("break_end"));
826        assert!(lines[1].contains("micro"));
827        assert!(lines[1].contains("completed"));
828        assert!(lines[2].contains("guard_suppress"));
829        assert!(lines[2].contains("dnd"));
830    }
831
832    #[test]
833    fn round_trip_event_through_json() {
834        let n = now();
835        let original = ev(
836            n,
837            EventPayload::BreakStart {
838                kind: BreakKind::Long,
839                duration_secs: 600,
840                enforceable: true,
841            },
842        );
843        let json = serde_json::to_string(&original).unwrap();
844        assert!(json.contains("\"type\":\"break_start\""));
845        assert!(json.contains("\"kind\":\"long\""));
846        let parsed: LoggedEvent = serde_json::from_str(&json).unwrap();
847        match parsed.event {
848            EventPayload::BreakStart {
849                duration_secs,
850                enforceable,
851                ..
852            } => {
853                assert_eq!(duration_secs, 600);
854                assert!(enforceable);
855            }
856            _ => panic!("wrong variant"),
857        }
858    }
859
860    #[test]
861    fn read_all_skips_blank_and_corrupt_lines() {
862        let dir = crate::test_support::temp_dir();
863        let path = dir.path().join("events.jsonl");
864        let valid = serde_json::to_string(&ev(
865            now(),
866            EventPayload::BreakEnd {
867                kind: BreakKind::Micro,
868                outcome: Outcome::Completed,
869            },
870        ))
871        .unwrap();
872        let body = format!("\n{valid}\nnot json\n\n{valid}\n");
873        std::fs::write(&path, body).unwrap();
874        let events = read_all(&path);
875        assert_eq!(events.len(), 2);
876    }
877
878    #[test]
879    fn read_all_returns_empty_when_missing() {
880        let path = PathBuf::from("/tmp/entracte-definitely-does-not-exist.jsonl");
881        let events = read_all(&path);
882        assert!(events.is_empty());
883    }
884
885    #[test]
886    fn typing_guard_reason_round_trips() {
887        let n = now();
888        let original = ev(
889            n,
890            EventPayload::GuardSuppress {
891                kind: BreakKind::Micro,
892                reason: GuardReason::Typing,
893            },
894        );
895        let json = serde_json::to_string(&original).unwrap();
896        assert!(json.contains("\"reason\":\"typing\""));
897        let parsed: LoggedEvent = serde_json::from_str(&json).unwrap();
898        match parsed.event {
899            EventPayload::GuardSuppress { reason, .. } => {
900                assert_eq!(reason, GuardReason::Typing);
901            }
902            _ => panic!("wrong variant"),
903        }
904    }
905
906    #[test]
907    fn typing_suppression_counts_into_digest() {
908        let n = now();
909        let events = vec![
910            ev(
911                n - Duration::hours(1),
912                EventPayload::GuardSuppress {
913                    kind: BreakKind::Long,
914                    reason: GuardReason::Typing,
915                },
916            ),
917            ev(
918                n - Duration::hours(2),
919                EventPayload::GuardSuppress {
920                    kind: BreakKind::Micro,
921                    reason: GuardReason::Typing,
922                },
923            ),
924        ];
925        let d = compute_digest(&events, "week", n);
926        let typing = d.suppressions.iter().find(|s| s.reason == "typing");
927        let typing = typing.expect("typing suppression present");
928        assert_eq!(typing.count, 2);
929        assert_eq!(typing.label, "Actively typing");
930    }
931
932    #[test]
933    fn typing_guard_reason_csv_uses_snake_case() {
934        let n = now();
935        let events = vec![ev(
936            n,
937            EventPayload::GuardSuppress {
938                kind: BreakKind::Long,
939                reason: GuardReason::Typing,
940            },
941        )];
942        let csv = export_csv(&events);
943        assert!(csv.lines().nth(1).unwrap().contains("typing"));
944    }
945
946    #[test]
947    fn by_weekday_indexes_monday_zero_to_sunday_six() {
948        let n = now();
949        let thursday = Local.with_ymd_and_hms(2026, 5, 14, 10, 0, 0).unwrap();
950        let sunday = Local.with_ymd_and_hms(2026, 5, 10, 10, 0, 0).unwrap();
951        let events = vec![
952            ev(
953                thursday,
954                EventPayload::BreakEnd {
955                    kind: BreakKind::Micro,
956                    outcome: Outcome::Completed,
957                },
958            ),
959            ev(
960                sunday,
961                EventPayload::BreakEnd {
962                    kind: BreakKind::Long,
963                    outcome: Outcome::Dismissed,
964                },
965            ),
966        ];
967        let d = compute_digest(&events, "week", n);
968        assert_eq!(d.by_weekday.len(), 7);
969        assert_eq!(d.by_weekday[3].weekday, 3);
970        assert_eq!(d.by_weekday[3].taken, 1);
971        assert_eq!(d.by_weekday[6].weekday, 6);
972        assert_eq!(d.by_weekday[6].dismissed, 1);
973    }
974
975    #[test]
976    fn by_weekday_ignores_sleep_prompts() {
977        let n = now();
978        let events = vec![ev(
979            n,
980            EventPayload::BreakEnd {
981                kind: BreakKind::Sleep,
982                outcome: Outcome::Completed,
983            },
984        )];
985        let d = compute_digest(&events, "week", n);
986        assert!(d.by_weekday.iter().all(|w| w.taken == 0));
987    }
988
989    #[test]
990    fn previous_period_tallies_one_window_back() {
991        let n = now();
992        let events = vec![
993            ev(
994                n - Duration::days(2),
995                EventPayload::BreakEnd {
996                    kind: BreakKind::Micro,
997                    outcome: Outcome::Completed,
998                },
999            ),
1000            ev(
1001                n - Duration::days(9),
1002                EventPayload::BreakEnd {
1003                    kind: BreakKind::Long,
1004                    outcome: Outcome::Completed,
1005                },
1006            ),
1007            ev(
1008                n - Duration::days(10),
1009                EventPayload::BreakEnd {
1010                    kind: BreakKind::Long,
1011                    outcome: Outcome::Dismissed,
1012                },
1013            ),
1014            ev(
1015                n - Duration::days(8),
1016                EventPayload::BreakPostponed {
1017                    kind: BreakKind::Micro,
1018                    minutes: 5,
1019                },
1020            ),
1021            ev(
1022                n - Duration::days(20),
1023                EventPayload::BreakSkipped {
1024                    kind: BreakKind::Micro,
1025                    source: SkipSource::User,
1026                },
1027            ),
1028        ];
1029        let d = compute_digest(&events, "week", n);
1030        assert_eq!(d.micro_taken + d.long_taken, 1);
1031        assert_eq!(d.previous.breaks_taken, 1);
1032        assert_eq!(d.previous.breaks_dismissed, 1);
1033        assert_eq!(d.previous.postponed_total, 1);
1034        assert_eq!(
1035            d.previous.skipped_total, 0,
1036            "events older than two windows back are excluded"
1037        );
1038    }
1039
1040    #[test]
1041    fn suppressions_by_kind_splits_reason_per_break_kind() {
1042        let n = now();
1043        let events = vec![
1044            ev(
1045                n - Duration::hours(1),
1046                EventPayload::GuardSuppress {
1047                    kind: BreakKind::Long,
1048                    reason: GuardReason::Dnd,
1049                },
1050            ),
1051            ev(
1052                n - Duration::hours(2),
1053                EventPayload::GuardSuppress {
1054                    kind: BreakKind::Long,
1055                    reason: GuardReason::Dnd,
1056                },
1057            ),
1058            ev(
1059                n - Duration::hours(3),
1060                EventPayload::GuardSuppress {
1061                    kind: BreakKind::Micro,
1062                    reason: GuardReason::Dnd,
1063                },
1064            ),
1065            ev(
1066                n - Duration::hours(4),
1067                EventPayload::GuardSuppress {
1068                    kind: BreakKind::Micro,
1069                    reason: GuardReason::Camera,
1070                },
1071            ),
1072        ];
1073        let d = compute_digest(&events, "week", n);
1074        assert_eq!(d.suppressions_by_kind.len(), 3);
1075        assert_eq!(d.suppressions_by_kind[0].kind, "long");
1076        assert_eq!(d.suppressions_by_kind[0].reason, "dnd");
1077        assert_eq!(d.suppressions_by_kind[0].count, 2);
1078        let micro_dnd = d
1079            .suppressions_by_kind
1080            .iter()
1081            .find(|s| s.kind == "micro" && s.reason == "dnd")
1082            .expect("micro/dnd present");
1083        assert_eq!(micro_dnd.count, 1);
1084        let total_dnd: u32 = d
1085            .suppressions_by_kind
1086            .iter()
1087            .filter(|s| s.reason == "dnd")
1088            .map(|s| s.count)
1089            .sum();
1090        let agg_dnd = d
1091            .suppressions
1092            .iter()
1093            .find(|s| s.reason == "dnd")
1094            .unwrap()
1095            .count;
1096        assert_eq!(
1097            total_dnd, agg_dnd,
1098            "per-kind split must sum to the flat suppressions count"
1099        );
1100    }
1101
1102    #[test]
1103    fn postpone_follow_through_taken_dismissed_skipped_unresolved() {
1104        let n = now();
1105        let events = vec![
1106            // Postponed and later taken
1107            ev(
1108                n - Duration::hours(5),
1109                EventPayload::BreakPostponed {
1110                    kind: BreakKind::Micro,
1111                    minutes: 5,
1112                },
1113            ),
1114            ev(
1115                n - Duration::hours(4),
1116                EventPayload::BreakEnd {
1117                    kind: BreakKind::Micro,
1118                    outcome: Outcome::Completed,
1119                },
1120            ),
1121            // Postponed and later dismissed
1122            ev(
1123                n - Duration::hours(3),
1124                EventPayload::BreakPostponed {
1125                    kind: BreakKind::Long,
1126                    minutes: 10,
1127                },
1128            ),
1129            ev(
1130                n - Duration::hours(2),
1131                EventPayload::BreakEnd {
1132                    kind: BreakKind::Long,
1133                    outcome: Outcome::Dismissed,
1134                },
1135            ),
1136            // Postponed and later skipped
1137            ev(
1138                n - Duration::hours(1) - Duration::minutes(30),
1139                EventPayload::BreakPostponed {
1140                    kind: BreakKind::Micro,
1141                    minutes: 5,
1142                },
1143            ),
1144            ev(
1145                n - Duration::hours(1),
1146                EventPayload::BreakSkipped {
1147                    kind: BreakKind::Micro,
1148                    source: SkipSource::User,
1149                },
1150            ),
1151            // Postponed with no resolution after it
1152            ev(
1153                n - Duration::minutes(10),
1154                EventPayload::BreakPostponed {
1155                    kind: BreakKind::Long,
1156                    minutes: 10,
1157                },
1158            ),
1159        ];
1160        let d = compute_digest(&events, "week", n);
1161        assert_eq!(d.postpone_follow_through.total, 4);
1162        assert_eq!(d.postpone_follow_through.taken, 1);
1163        assert_eq!(d.postpone_follow_through.dismissed, 1);
1164        assert_eq!(d.postpone_follow_through.skipped, 1);
1165        assert_eq!(d.postpone_follow_through.unresolved, 1);
1166    }
1167
1168    #[test]
1169    fn postpone_follow_through_skips_intervening_other_kind() {
1170        let n = now();
1171        let events = vec![
1172            ev(
1173                n - Duration::hours(3),
1174                EventPayload::BreakPostponed {
1175                    kind: BreakKind::Long,
1176                    minutes: 5,
1177                },
1178            ),
1179            // BreakEnd of a different kind — must not resolve the long postpone
1180            ev(
1181                n - Duration::hours(2),
1182                EventPayload::BreakEnd {
1183                    kind: BreakKind::Micro,
1184                    outcome: Outcome::Completed,
1185                },
1186            ),
1187            ev(
1188                n - Duration::hours(1),
1189                EventPayload::BreakEnd {
1190                    kind: BreakKind::Long,
1191                    outcome: Outcome::Completed,
1192                },
1193            ),
1194        ];
1195        let d = compute_digest(&events, "week", n);
1196        assert_eq!(d.postpone_follow_through.total, 1);
1197        assert_eq!(d.postpone_follow_through.taken, 1);
1198        assert_eq!(d.postpone_follow_through.unresolved, 0);
1199    }
1200
1201    #[test]
1202    fn postpone_follow_through_only_counts_postpones_in_range() {
1203        let n = now();
1204        let events = vec![
1205            ev(
1206                n - Duration::days(20),
1207                EventPayload::BreakPostponed {
1208                    kind: BreakKind::Micro,
1209                    minutes: 5,
1210                },
1211            ),
1212            ev(
1213                n - Duration::days(19),
1214                EventPayload::BreakEnd {
1215                    kind: BreakKind::Micro,
1216                    outcome: Outcome::Completed,
1217                },
1218            ),
1219        ];
1220        let d = compute_digest(&events, "week", n);
1221        assert_eq!(
1222            d.postpone_follow_through.total, 0,
1223            "postpone outside the week range should not contribute"
1224        );
1225    }
1226}