Initial commit -- Core Cooldown v0.1.0

This commit is contained in:
2026-02-07 01:12:32 +02:00
commit 48cbbfa552
47 changed files with 15106 additions and 0 deletions

199
src-tauri/src/stats.rs Normal file
View File

@@ -0,0 +1,199 @@
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<String, DayRecord>,
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<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) {
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<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
}
}