use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; /// A single day's break statistics. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct DayRecord { pub date: String, // YYYY-MM-DD pub breaks_completed: u32, pub breaks_skipped: u32, pub breaks_snoozed: u32, pub total_break_time_secs: u64, pub natural_breaks: u32, pub natural_break_time_secs: u64, } /// Persistent stats stored as JSON. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct StatsData { pub days: HashMap, pub current_streak: u32, pub best_streak: u32, } /// Runtime stats manager. pub struct Stats { pub data: StatsData, } /// Snapshot sent to the frontend. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct StatsSnapshot { pub today_completed: u32, pub today_skipped: u32, pub today_snoozed: u32, pub today_break_time_secs: u64, pub today_natural_breaks: u32, pub today_natural_break_time_secs: u64, pub compliance_rate: f64, pub current_streak: u32, pub best_streak: u32, } impl Stats { /// Portable: stats file lives next to the exe fn stats_path() -> Option { let exe_path = std::env::current_exe().ok()?; let exe_dir = exe_path.parent()?; Some(exe_dir.join("stats.json")) } pub fn load_or_default() -> Self { let data = if let Some(path) = Self::stats_path() { if path.exists() { fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { StatsData::default() } } else { StatsData::default() }; Stats { data } } fn save(&self) { if let Some(path) = Self::stats_path() { if let Ok(json) = serde_json::to_string_pretty(&self.data) { let _ = fs::write(path, json); } } } fn today_key() -> String { chrono::Local::now().format("%Y-%m-%d").to_string() } fn today_mut(&mut self) -> &mut DayRecord { let key = Self::today_key(); self.data .days .entry(key.clone()) .or_insert_with(|| DayRecord { date: key, ..Default::default() }) } pub fn record_break_completed(&mut self, duration_secs: u64) { let day = self.today_mut(); day.breaks_completed += 1; day.total_break_time_secs += duration_secs; self.update_streak(); self.save(); } pub fn record_break_skipped(&mut self) { let day = self.today_mut(); day.breaks_skipped += 1; self.save(); } pub fn record_break_snoozed(&mut self) { let day = self.today_mut(); day.breaks_snoozed += 1; self.save(); } pub fn record_natural_break(&mut self, duration_secs: u64) { let day = self.today_mut(); day.natural_breaks += 1; day.natural_break_time_secs += duration_secs; self.save(); } fn update_streak(&mut self) { // Calculate streak: consecutive days with at least 1 break completed let mut streak = 0u32; let today = chrono::Local::now().date_naive(); for i in 0.. { let day = today - chrono::Duration::days(i); let key = day.format("%Y-%m-%d").to_string(); if let Some(record) = self.data.days.get(&key) { if record.breaks_completed > 0 { streak += 1; } else { break; } } else { if i == 0 { // Today hasn't been recorded yet (but we just did), continue streak += 1; } else { break; } } } self.data.current_streak = streak; if streak > self.data.best_streak { self.data.best_streak = streak; } } pub fn snapshot(&self) -> StatsSnapshot { let key = Self::today_key(); let today = self.data.days.get(&key); let completed = today.map(|d| d.breaks_completed).unwrap_or(0); let skipped = today.map(|d| d.breaks_skipped).unwrap_or(0); let snoozed = today.map(|d| d.breaks_snoozed).unwrap_or(0); let break_time = today.map(|d| d.total_break_time_secs).unwrap_or(0); let natural_breaks = today.map(|d| d.natural_breaks).unwrap_or(0); let natural_break_time = today.map(|d| d.natural_break_time_secs).unwrap_or(0); let total = completed + skipped; let compliance = if total > 0 { completed as f64 / total as f64 } else { 1.0 }; StatsSnapshot { today_completed: completed, today_skipped: skipped, today_snoozed: snoozed, today_break_time_secs: break_time, today_natural_breaks: natural_breaks, today_natural_break_time_secs: natural_break_time, compliance_rate: compliance, current_streak: self.data.current_streak, best_streak: self.data.best_streak, } } /// Get recent N days of history, sorted chronologically. pub fn recent_days(&self, n: u32) -> Vec { let today = chrono::Local::now().date_naive(); let mut records = Vec::new(); for i in (0..n).rev() { let day = today - chrono::Duration::days(i as i64); let key = day.format("%Y-%m-%d").to_string(); let record = self.data.days.get(&key).cloned().unwrap_or(DayRecord { date: key, ..Default::default() }); records.push(record); } records } }