// SPDX-License-Identifier: LicenseRef-Commercial // Analytics Dashboard — historical cost tracking, model usage, token trends. use serde::Serialize; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AnalyticsSummary { pub total_sessions: i64, pub total_cost_usd: f64, pub total_tokens: i64, pub total_turns: i64, pub total_tool_calls: i64, pub avg_cost_per_session: f64, pub avg_tokens_per_session: f64, pub period_days: i64, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DailyStats { pub date: String, pub session_count: i64, pub cost_usd: f64, pub tokens: i64, pub turns: i64, pub tool_calls: i64, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ModelBreakdown { pub model: String, pub session_count: i64, pub total_cost_usd: f64, pub total_tokens: i64, pub avg_cost_per_session: f64, } #[tauri::command] pub fn pro_analytics_summary(project_id: String, days: Option) -> Result { let conn = super::open_sessions_db()?; let period = days.unwrap_or(30); let cutoff = now_epoch() - (period * 86400); let mut stmt = conn.prepare( "SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0), COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0) FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2" ).map_err(|e| format!("Query failed: {e}"))?; let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| { let count: i64 = row.get(0)?; let cost: f64 = row.get(1)?; let tokens: i64 = row.get(2)?; let turns: i64 = row.get(3)?; let tools: i64 = row.get(4)?; Ok(AnalyticsSummary { total_sessions: count, total_cost_usd: cost, total_tokens: tokens, total_turns: turns, total_tool_calls: tools, avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 }, avg_tokens_per_session: if count > 0 { tokens as f64 / count as f64 } else { 0.0 }, period_days: period, }) }).map_err(|e| format!("Query failed: {e}"))?; Ok(result) } #[tauri::command] pub fn pro_analytics_daily(project_id: String, days: Option) -> Result, String> { let conn = super::open_sessions_db()?; let period = days.unwrap_or(30); let cutoff = now_epoch() - (period * 86400); let mut stmt = conn.prepare( "SELECT date(end_time, 'unixepoch') as day, COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0), COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0) FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 GROUP BY day ORDER BY day ASC" ).map_err(|e| format!("Query failed: {e}"))?; let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| { Ok(DailyStats { date: row.get(0)?, session_count: row.get(1)?, cost_usd: row.get(2)?, tokens: row.get(3)?, turns: row.get(4)?, tool_calls: row.get(5)?, }) }).map_err(|e| format!("Query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; Ok(rows) } #[tauri::command] pub fn pro_analytics_model_breakdown(project_id: String, days: Option) -> Result, String> { let conn = super::open_sessions_db()?; let period = days.unwrap_or(30); let cutoff = now_epoch() - (period * 86400); let mut stmt = conn.prepare( "SELECT COALESCE(model, 'unknown'), COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0) FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 GROUP BY model ORDER BY SUM(cost_usd) DESC" ).map_err(|e| format!("Query failed: {e}"))?; let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| { let count: i64 = row.get(1)?; let cost: f64 = row.get(2)?; Ok(ModelBreakdown { model: row.get(0)?, session_count: count, total_cost_usd: cost, total_tokens: row.get(3)?, avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 }, }) }).map_err(|e| format!("Query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; Ok(rows) } pub(crate) fn now_epoch() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } #[cfg(test)] mod tests { use super::*; #[test] fn test_analytics_summary_struct() { let s = AnalyticsSummary { total_sessions: 5, total_cost_usd: 1.25, total_tokens: 50000, total_turns: 30, total_tool_calls: 100, avg_cost_per_session: 0.25, avg_tokens_per_session: 10000.0, period_days: 30, }; let json = serde_json::to_string(&s).unwrap(); assert!(json.contains("totalSessions")); assert!(json.contains("avgCostPerSession")); } #[test] fn test_model_breakdown_serializes_camel_case() { let m = ModelBreakdown { model: "opus".into(), session_count: 3, total_cost_usd: 0.75, total_tokens: 30000, avg_cost_per_session: 0.25, }; let json = serde_json::to_string(&m).unwrap(); assert!(json.contains("sessionCount")); assert!(json.contains("totalCostUsd")); } }