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#[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 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
431fn 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
595pub 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 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 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 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 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 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}