Initial commit -- Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
This commit is contained in:
199
src-tauri/src/stats.rs
Normal file
199
src-tauri/src/stats.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user