279 lines
8.4 KiB
Rust
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
|
|
}
|
|
}
|