// 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(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(SUM(peak_tokens), 0) AS total_tokens, COALESCE(SUM(turn_count), 0) AS total_turns, COALESCE(SUM(tool_call_count), 0) AS total_tools 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("cnt")?; let cost: f64 = row.get("total_cost")?; let tokens: i64 = row.get("total_tokens")?; let turns: i64 = row.get("total_turns")?; let tools: i64 = row.get("total_tools")?; 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(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(SUM(peak_tokens), 0) AS total_tokens, COALESCE(SUM(turn_count), 0) AS total_turns, COALESCE(SUM(tool_call_count), 0) AS total_tools 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("day")?, session_count: row.get("cnt")?, cost_usd: row.get("total_cost")?, tokens: row.get("total_tokens")?, turns: row.get("total_turns")?, tool_calls: row.get("total_tools")?, }) }).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') AS model_name, COUNT(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(SUM(peak_tokens), 0) AS total_tokens 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("cnt")?; let cost: f64 = row.get("total_cost")?; Ok(ModelBreakdown { model: row.get("model_name")?, session_count: count, total_cost_usd: cost, total_tokens: row.get("total_tokens")?, 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")); } }