use std::collections::HashMap;
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
fn default_version() -> u32 {
1
}
fn default_today_seconds_practiced() -> u32 {
0
}
fn default_today_date() -> String {
String::new()
}
fn default_legacy_minutes() -> Option<u32> {
None
}
fn default_last_lesson() -> Option<SavedLessonResult> {
None
}
fn default_lesson_history() -> Vec<SavedLessonResult> {
Vec::new()
}
pub fn today_date_string() -> String {
use std::time::SystemTime;
let secs = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64,
Err(_) => return String::new(),
};
date_string_from_unix_secs(secs)
}
fn date_string_from_unix_secs(secs: i64) -> String {
let z: i64 = secs.div_euclid(86_400);
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe: u32 = (z - era * 146_097) as u32; let yoe: u32 = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y: i64 = yoe as i64 + era * 400;
let doy: u32 = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp: u32 = (5 * doy + 2) / 153; let d: u32 = doy - (153 * mp + 2) / 5 + 1; let m: u32 = if mp < 10 { mp + 3 } else { mp - 9 }; let year: i64 = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", year, m, d)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedKeyStats {
pub attempts: u32,
pub errors: u32,
pub filtered_time_ms: f64,
pub best_filtered_time_ms: f64,
pub recent_times_ms: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedLessonResult {
pub wpm: f64,
pub accuracy: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedStats {
#[serde(default = "default_version")]
pub version: u32,
pub keys: HashMap<char, SavedKeyStats>,
pub unlocked_letters: Vec<char>,
pub total_lessons: u32,
pub last_session: String,
#[serde(default = "default_today_seconds_practiced")]
pub today_seconds_practiced: u32,
#[serde(default = "default_legacy_minutes", skip_serializing)]
pub today_minutes_practiced: Option<u32>,
#[serde(default = "default_today_date")]
pub today_date: String,
#[serde(default = "default_last_lesson")]
pub last_lesson: Option<SavedLessonResult>,
#[serde(default = "default_lesson_history")]
pub lesson_history: Vec<SavedLessonResult>,
}
impl SavedStats {
pub fn path() -> Option<PathBuf> {
ProjectDirs::from("", "", "keybr-tui").map(|dirs| dirs.data_dir().join("stats.json"))
}
pub fn load() -> Option<Self> {
let path = Self::path()?;
if !path.exists() {
return None;
}
match std::fs::read_to_string(&path) {
Ok(contents) => match serde_json::from_str::<SavedStats>(&contents) {
Ok(mut stats) => {
let today = today_date_string();
if stats.version < 2 {
stats.today_seconds_practiced = 0;
stats.today_date = String::new();
stats.version = 2;
} else {
if let Some(legacy_min) = stats.today_minutes_practiced.take() {
if stats.today_seconds_practiced == 0 && legacy_min > 0 {
stats.today_seconds_practiced = legacy_min.saturating_mul(60);
}
}
if stats.today_date != today {
stats.today_seconds_practiced = 0;
stats.today_date = today;
}
}
Some(stats)
}
Err(e) => {
eprintln!(
"Warning: malformed stats at {}: {}. Starting fresh.",
path.display(),
e
);
None
}
},
Err(e) => {
eprintln!(
"Warning: could not read stats at {}: {}. Starting fresh.",
path.display(),
e
);
None
}
}
}
pub fn save(&self) -> color_eyre::Result<()> {
let path = match Self::path() {
Some(p) => p,
None => return Ok(()),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)?;
std::fs::write(&path, contents)?;
Ok(())
}
pub fn delete() -> color_eyre::Result<bool> {
if let Some(path) = Self::path() {
if path.exists() {
std::fs::remove_file(&path)?;
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stats_serialization_roundtrip() {
let mut keys = HashMap::new();
keys.insert(
'e',
SavedKeyStats {
attempts: 100,
errors: 5,
filtered_time_ms: 350.0,
best_filtered_time_ms: 280.0,
recent_times_ms: vec![300, 310, 290, 320],
},
);
keys.insert(
't',
SavedKeyStats {
attempts: 80,
errors: 3,
filtered_time_ms: 400.0,
best_filtered_time_ms: 350.0,
recent_times_ms: vec![380, 390, 410],
},
);
let stats = SavedStats {
version: 2,
keys,
unlocked_letters: vec!['e', 't', 'a', 'o', 'i', 'n', 's'],
total_lessons: 12,
last_session: "2026-03-29T12:00:00Z".to_string(),
today_seconds_practiced: 1042,
today_minutes_practiced: None,
today_date: "2026-03-29".to_string(),
last_lesson: Some(SavedLessonResult {
wpm: 42.5,
accuracy: 96.0,
}),
lesson_history: vec![
SavedLessonResult {
wpm: 38.0,
accuracy: 94.0,
},
SavedLessonResult {
wpm: 42.5,
accuracy: 96.0,
},
],
};
let json = serde_json::to_string_pretty(&stats).unwrap();
let deserialized: SavedStats = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.version, 2);
assert_eq!(deserialized.total_lessons, 12);
assert_eq!(deserialized.unlocked_letters.len(), 7);
assert_eq!(deserialized.keys.len(), 2);
assert_eq!(deserialized.today_seconds_practiced, 1042);
assert_eq!(deserialized.today_date, "2026-03-29");
let last = deserialized.last_lesson.as_ref().unwrap();
assert!((last.wpm - 42.5).abs() < f64::EPSILON);
assert!((last.accuracy - 96.0).abs() < f64::EPSILON);
assert_eq!(deserialized.lesson_history.len(), 2);
let e_stats = deserialized.keys.get(&'e').unwrap();
assert_eq!(e_stats.attempts, 100);
assert_eq!(e_stats.errors, 5);
assert!((e_stats.filtered_time_ms - 350.0).abs() < f64::EPSILON);
assert_eq!(e_stats.recent_times_ms.len(), 4);
}
#[test]
fn stats_defaults_version() {
let json = r#"{
"keys": {},
"unlocked_letters": [],
"total_lessons": 0,
"last_session": ""
}"#;
let stats: SavedStats = serde_json::from_str(json).unwrap();
assert_eq!(stats.version, 1);
assert_eq!(stats.today_seconds_practiced, 0);
assert_eq!(stats.today_minutes_practiced, None);
assert_eq!(stats.today_date, "");
assert!(stats.last_lesson.is_none());
assert!(stats.lesson_history.is_empty());
}
#[test]
fn legacy_minutes_field_deserializes() {
let json = r#"{
"version": 2,
"keys": {},
"unlocked_letters": [],
"total_lessons": 0,
"last_session": "",
"today_minutes_practiced": 7,
"today_date": "2026-03-29"
}"#;
let stats: SavedStats = serde_json::from_str(json).unwrap();
assert_eq!(stats.today_minutes_practiced, Some(7));
assert_eq!(stats.today_seconds_practiced, 0);
}
#[test]
fn date_string_from_known_unix_seconds() {
assert_eq!(date_string_from_unix_secs(1_736_899_200), "2025-01-15");
assert_eq!(date_string_from_unix_secs(0), "1970-01-01");
assert_eq!(date_string_from_unix_secs(951_868_799), "2000-02-29");
assert_eq!(date_string_from_unix_secs(951_868_800), "2000-03-01");
}
#[test]
fn today_date_string_is_well_formed() {
let s = today_date_string();
assert_eq!(s.len(), 10, "expected YYYY-MM-DD, got {:?}", s);
let bytes = s.as_bytes();
assert_eq!(bytes[4], b'-');
assert_eq!(bytes[7], b'-');
}
#[test]
fn stats_path_is_some() {
assert!(SavedStats::path().is_some());
}
}