Files
core-cooldown/src-tauri/src/stats.rs

279 lines
8.4 KiB
Rust

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<String, DayRecord>,
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<u32>,
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<PathBuf> {
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<u32> {
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<DayRecord> {
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<WeekSummary> {
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
}
}