use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; #[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, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct StatsData { pub days: HashMap, pub current_streak: u32, pub best_streak: u32, } pub struct Stats { pub data: StatsData, } #[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, // F10: Daily goal pub daily_goal_progress: u32, pub daily_goal_met: bool, } pub struct BreakCompletedResult { pub milestone_reached: Option, pub daily_goal_just_met: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WeekSummary { pub week_start: String, pub total_completed: u32, pub total_skipped: u32, pub total_break_time_secs: u64, pub compliance_rate: f64, pub avg_daily_completed: f64, } const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365]; impl Stats { // Portable: 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, daily_goal: u32) -> BreakCompletedResult { let day = self.today_mut(); let was_below_goal = day.breaks_completed < daily_goal; day.breaks_completed += 1; day.total_break_time_secs += duration_secs; let now_at_goal = day.breaks_completed >= daily_goal; self.update_streak(); self.save(); let milestone = self.check_milestone(); let daily_goal_just_met = was_below_goal && now_at_goal && daily_goal > 0; BreakCompletedResult { milestone_reached: milestone, daily_goal_just_met, } } 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; } } fn check_milestone(&self) -> Option { let streak = self.data.current_streak; if MILESTONES.contains(&streak) { Some(streak) } else { None } } pub fn snapshot(&self, daily_goal: u32) -> 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, daily_goal_progress: completed, daily_goal_met: daily_goal > 0 && completed >= daily_goal, } } 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 } pub fn weekly_summary(&self, weeks: u32) -> Vec { let today = chrono::Local::now().date_naive(); let mut summaries = Vec::new(); for w in 0..weeks { let week_end = today - chrono::Duration::days((w * 7) as i64); let week_start = week_end - chrono::Duration::days(6); let mut total_completed = 0u32; let mut total_skipped = 0u32; let mut total_break_time = 0u64; for d in 0..7 { let day = week_start + chrono::Duration::days(d); let key = day.format("%Y-%m-%d").to_string(); if let Some(record) = self.data.days.get(&key) { total_completed += record.breaks_completed; total_skipped += record.breaks_skipped; total_break_time += record.total_break_time_secs; } } let total = total_completed + total_skipped; let compliance = if total > 0 { total_completed as f64 / total as f64 } else { 1.0 }; summaries.push(WeekSummary { week_start: week_start.format("%Y-%m-%d").to_string(), total_completed, total_skipped, total_break_time_secs: total_break_time, compliance_rate: compliance, avg_daily_completed: total_completed as f64 / 7.0, }); } summaries } }