epik/stats/
mod.rs

1// Statistics tracking for games
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8use crate::config::Config;
9use crate::Result;
10
11// Playtime and launch statistics for a game
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct GameStats {
14    pub app_name: String,
15    pub total_playtime_seconds: u64,
16    pub launch_count: u32,
17    pub last_played: Option<DateTime<Utc>>,
18    pub first_played: Option<DateTime<Utc>>,
19    // Per-session playtime records
20    #[serde(default)]
21    pub sessions: Vec<PlaySession>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PlaySession {
26    pub started: DateTime<Utc>,
27    pub ended: DateTime<Utc>,
28    pub duration_seconds: u64,
29}
30
31impl GameStats {
32    pub fn new(app_name: String) -> Self {
33        Self {
34            app_name,
35            total_playtime_seconds: 0,
36            launch_count: 0,
37            last_played: None,
38            first_played: None,
39            sessions: Vec::new(),
40        }
41    }
42
43    pub fn record_launch(&mut self) {
44        self.launch_count += 1;
45        let now = Utc::now();
46        
47        if self.first_played.is_none() {
48            self.first_played = Some(now);
49        }
50    }
51
52    pub fn record_session(&mut self, started: DateTime<Utc>, ended: DateTime<Utc>) {
53        let duration = (ended - started).num_seconds().max(0) as u64;
54        
55        self.total_playtime_seconds += duration;
56        self.last_played = Some(ended);
57        
58        self.sessions.push(PlaySession {
59            started,
60            ended,
61            duration_seconds: duration,
62        });
63    }
64
65    pub fn total_playtime_hours(&self) -> f64 {
66        self.total_playtime_seconds as f64 / 3600.0
67    }
68
69    pub fn save(&self, config: &Config) -> Result<()> {
70        let stats_dir = Self::stats_dir(config)?;
71        fs::create_dir_all(&stats_dir)?;
72
73        let stats_file = stats_dir.join(format!("{}.json", self.app_name));
74        let contents = serde_json::to_string_pretty(self)?;
75        fs::write(&stats_file, contents)?;
76
77        Ok(())
78    }
79
80    pub fn load(config: &Config, app_name: &str) -> Result<Self> {
81        let stats_dir = Self::stats_dir(config)?;
82        let stats_file = stats_dir.join(format!("{}.json", app_name));
83
84        if !stats_file.exists() {
85            return Ok(Self::new(app_name.to_string()));
86        }
87
88        let contents = fs::read_to_string(&stats_file)?;
89        Ok(serde_json::from_str(&contents)?)
90    }
91
92    fn stats_dir(_config: &Config) -> Result<PathBuf> {
93        let data_dir = Config::data_dir()?;
94        Ok(data_dir.join("stats"))
95    }
96}
97
98// Achievement tracking (placeholder for future implementation)
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Achievement {
101    pub id: String,
102    pub name: String,
103    pub description: String,
104    pub unlocked: bool,
105    pub unlock_time: Option<DateTime<Utc>>,
106    pub icon_url: Option<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct GameAchievements {
111    pub app_name: String,
112    pub achievements: Vec<Achievement>,
113    pub total_achievements: usize,
114    pub unlocked_count: usize,
115}
116
117impl GameAchievements {
118    pub fn new(app_name: String) -> Self {
119        Self {
120            app_name,
121            achievements: Vec::new(),
122            total_achievements: 0,
123            unlocked_count: 0,
124        }
125    }
126
127    pub fn completion_percentage(&self) -> f32 {
128        if self.total_achievements == 0 {
129            return 0.0;
130        }
131        (self.unlocked_count as f32 / self.total_achievements as f32) * 100.0
132    }
133}
134
135// Global statistics manager
136pub struct StatsManager {
137    config: Config,
138    stats_cache: HashMap<String, GameStats>,
139}
140
141impl StatsManager {
142    pub fn new(config: Config) -> Self {
143        Self {
144            config,
145            stats_cache: HashMap::new(),
146        }
147    }
148
149    pub fn get_stats(&mut self, app_name: &str) -> Result<&GameStats> {
150        if !self.stats_cache.contains_key(app_name) {
151            let stats = GameStats::load(&self.config, app_name)?;
152            self.stats_cache.insert(app_name.to_string(), stats);
153        }
154
155        Ok(self.stats_cache.get(app_name).unwrap())
156    }
157
158    pub fn record_launch(&mut self, app_name: &str) -> Result<()> {
159        let stats = self.stats_cache
160            .entry(app_name.to_string())
161            .or_insert_with(|| GameStats::new(app_name.to_string()));
162
163        stats.record_launch();
164        stats.save(&self.config)?;
165
166        Ok(())
167    }
168
169    pub fn record_session(
170        &mut self,
171        app_name: &str,
172        started: DateTime<Utc>,
173        ended: DateTime<Utc>,
174    ) -> Result<()> {
175        let stats = self.stats_cache
176            .entry(app_name.to_string())
177            .or_insert_with(|| GameStats::new(app_name.to_string()));
178
179        stats.record_session(started, ended);
180        stats.save(&self.config)?;
181
182        Ok(())
183    }
184
185    pub fn get_all_stats(&self) -> Vec<&GameStats> {
186        self.stats_cache.values().collect()
187    }
188
189    pub fn total_playtime_hours(&self) -> f64 {
190        self.stats_cache
191            .values()
192            .map(|s| s.total_playtime_hours())
193            .sum()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_game_stats_creation() {
203        let stats = GameStats::new("test_game".to_string());
204        assert_eq!(stats.app_name, "test_game");
205        assert_eq!(stats.total_playtime_seconds, 0);
206        assert_eq!(stats.launch_count, 0);
207    }
208
209    #[test]
210    fn test_record_launch() {
211        let mut stats = GameStats::new("test_game".to_string());
212        stats.record_launch();
213        assert_eq!(stats.launch_count, 1);
214        assert!(stats.first_played.is_some());
215    }
216
217    #[test]
218    fn test_record_session() {
219        let mut stats = GameStats::new("test_game".to_string());
220        let start = Utc::now();
221        let end = start + chrono::Duration::hours(2);
222        
223        stats.record_session(start, end);
224        
225        assert_eq!(stats.total_playtime_hours(), 2.0);
226        assert_eq!(stats.sessions.len(), 1);
227        assert!(stats.last_played.is_some());
228    }
229}