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#[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 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
442pub 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}