arct_tui/
analytics.rs

1//! User analytics and progress tracking
2
3use anyhow::Result;
4use chrono::Local;
5use rusqlite::{Connection, params};
6use std::path::PathBuf;
7
8/// Analytics database for tracking user progress
9pub struct Analytics {
10    conn: Connection,
11}
12
13impl Analytics {
14    /// Create or open the analytics database
15    pub fn new() -> Result<Self> {
16        let db_path = Self::get_db_path()?;
17        let conn = Connection::open(db_path)?;
18
19        // Create tables if they don't exist
20        conn.execute(
21            "CREATE TABLE IF NOT EXISTS commands (
22                id INTEGER PRIMARY KEY AUTOINCREMENT,
23                timestamp TEXT NOT NULL,
24                command TEXT NOT NULL,
25                success INTEGER NOT NULL,
26                working_dir TEXT,
27                session_id TEXT
28            )",
29            [],
30        )?;
31
32        conn.execute(
33            "CREATE TABLE IF NOT EXISTS sessions (
34                id TEXT PRIMARY KEY,
35                start_time TEXT NOT NULL,
36                end_time TEXT,
37                commands_count INTEGER DEFAULT 0
38            )",
39            [],
40        )?;
41
42        conn.execute(
43            "CREATE TABLE IF NOT EXISTS daily_stats (
44                date TEXT PRIMARY KEY,
45                commands_count INTEGER DEFAULT 0,
46                unique_commands INTEGER DEFAULT 0,
47                session_count INTEGER DEFAULT 0
48            )",
49            [],
50        )?;
51
52        Ok(Self { conn })
53    }
54
55    /// Get the database file path
56    fn get_db_path() -> Result<PathBuf> {
57        let data_dir = dirs::data_local_dir()
58            .ok_or_else(|| anyhow::anyhow!("Could not find local data directory"))?;
59
60        let arct_dir = data_dir.join("arct");
61        if !arct_dir.exists() {
62            std::fs::create_dir_all(&arct_dir)?;
63        }
64
65        Ok(arct_dir.join("analytics.db"))
66    }
67
68    /// Record a command execution
69    pub fn record_command(
70        &self,
71        command: &str,
72        success: bool,
73        working_dir: &str,
74        session_id: &str,
75    ) -> Result<()> {
76        let timestamp = Local::now().to_rfc3339();
77
78        self.conn.execute(
79            "INSERT INTO commands (timestamp, command, success, working_dir, session_id)
80             VALUES (?1, ?2, ?3, ?4, ?5)",
81            params![timestamp, command, success as i32, working_dir, session_id],
82        )?;
83
84        // Update daily stats
85        self.update_daily_stats()?;
86
87        Ok(())
88    }
89
90    /// Update daily statistics
91    fn update_daily_stats(&self) -> Result<()> {
92        let today = Local::now().format("%Y-%m-%d").to_string();
93
94        // Count commands today
95        let commands_today: i64 = self.conn.query_row(
96            "SELECT COUNT(*) FROM commands WHERE DATE(timestamp) = ?1",
97            params![today],
98            |row| row.get(0),
99        )?;
100
101        // Count unique commands today
102        let unique_today: i64 = self.conn.query_row(
103            "SELECT COUNT(DISTINCT command) FROM commands WHERE DATE(timestamp) = ?1",
104            params![today],
105            |row| row.get(0),
106        )?;
107
108        // Upsert daily stats
109        self.conn.execute(
110            "INSERT OR REPLACE INTO daily_stats (date, commands_count, unique_commands)
111             VALUES (?1, ?2, ?3)",
112            params![today, commands_today, unique_today],
113        )?;
114
115        Ok(())
116    }
117
118    /// Get total commands executed
119    pub fn get_total_commands(&self) -> Result<i64> {
120        let count: i64 = self.conn.query_row(
121            "SELECT COUNT(*) FROM commands",
122            [],
123            |row| row.get(0),
124        )?;
125        Ok(count)
126    }
127
128    /// Get commands executed today
129    pub fn get_commands_today(&self) -> Result<i64> {
130        let today = Local::now().format("%Y-%m-%d").to_string();
131        let count: i64 = self.conn.query_row(
132            "SELECT COUNT(*) FROM commands WHERE DATE(timestamp) = ?1",
133            params![today],
134            |row| row.get(0),
135        )?;
136        Ok(count)
137    }
138
139    /// Get unique commands learned (all time)
140    pub fn get_unique_commands(&self) -> Result<i64> {
141        let count: i64 = self.conn.query_row(
142            "SELECT COUNT(DISTINCT command) FROM commands",
143            [],
144            |row| row.get(0),
145        )?;
146        Ok(count)
147    }
148
149    /// Get most used commands (top 5)
150    pub fn get_top_commands(&self, limit: usize) -> Result<Vec<(String, i64)>> {
151        let mut stmt = self.conn.prepare(
152            "SELECT command, COUNT(*) as count
153             FROM commands
154             GROUP BY command
155             ORDER BY count DESC
156             LIMIT ?1"
157        )?;
158
159        let commands = stmt.query_map(params![limit], |row| {
160            Ok((row.get(0)?, row.get(1)?))
161        })?
162        .collect::<Result<Vec<_>, _>>()?;
163
164        Ok(commands)
165    }
166
167    /// Get current streak (consecutive days with activity)
168    pub fn get_streak(&self) -> Result<i64> {
169        let mut streak = 0i64;
170        let mut current_date = Local::now().naive_local().date();
171
172        loop {
173            let date_str = current_date.format("%Y-%m-%d").to_string();
174            let count: i64 = self.conn.query_row(
175                "SELECT COUNT(*) FROM commands WHERE DATE(timestamp) = ?1",
176                params![date_str],
177                |row| row.get(0),
178            )?;
179
180            if count > 0 {
181                streak += 1;
182                current_date = current_date.pred_opt().unwrap_or(current_date);
183            } else if streak == 0 {
184                // No commands today, check if there were any yesterday
185                current_date = current_date.pred_opt().unwrap_or(current_date);
186                let date_str = current_date.format("%Y-%m-%d").to_string();
187                let yesterday_count: i64 = self.conn.query_row(
188                    "SELECT COUNT(*) FROM commands WHERE DATE(timestamp) = ?1",
189                    params![date_str],
190                    |row| row.get(0),
191                )?;
192
193                if yesterday_count > 0 {
194                    streak = 1;
195                    current_date = current_date.pred_opt().unwrap_or(current_date);
196                } else {
197                    break;
198                }
199            } else {
200                break;
201            }
202        }
203
204        Ok(streak)
205    }
206
207    /// Calculate skill level based on command diversity and usage
208    pub fn get_skill_level(&self) -> Result<String> {
209        let unique = self.get_unique_commands()?;
210        let _total = self.get_total_commands()?; // Reserved for future skill calculation refinement
211
212        let level = if unique < 5 {
213            "Beginner"
214        } else if unique < 15 {
215            "Learning"
216        } else if unique < 30 {
217            "Intermediate"
218        } else if unique < 50 {
219            "Advanced"
220        } else {
221            "Expert"
222        };
223
224        Ok(level.to_string())
225    }
226
227    /// Get commands executed in the last 7 days
228    pub fn get_weekly_activity(&self) -> Result<Vec<(String, i64)>> {
229        let mut activity = Vec::new();
230
231        for days_ago in (0..7).rev() {
232            let date = Local::now().naive_local().date() - chrono::Duration::days(days_ago);
233            let date_str = date.format("%Y-%m-%d").to_string();
234
235            let count: i64 = self.conn.query_row(
236                "SELECT COUNT(*) FROM commands WHERE DATE(timestamp) = ?1",
237                params![date_str],
238                |row| row.get(0),
239            ).unwrap_or(0);
240
241            let day_name = date.format("%a").to_string();
242            activity.push((day_name, count));
243        }
244
245        Ok(activity)
246    }
247}
248
249/// User analytics summary for display
250#[derive(Debug, Clone)]
251pub struct AnalyticsSummary {
252    pub total_commands: i64,
253    pub commands_today: i64,
254    pub unique_commands: i64,
255    pub streak: i64,
256    pub skill_level: String,
257    pub top_commands: Vec<(String, i64)>,
258}
259
260impl Analytics {
261    /// Get a summary of all analytics
262    pub fn get_summary(&self) -> Result<AnalyticsSummary> {
263        Ok(AnalyticsSummary {
264            total_commands: self.get_total_commands()?,
265            commands_today: self.get_commands_today()?,
266            unique_commands: self.get_unique_commands()?,
267            streak: self.get_streak()?,
268            skill_level: self.get_skill_level()?,
269            top_commands: self.get_top_commands(3)?,
270        })
271    }
272}