diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e0d0d8f..68a5e5c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -535,9 +547,11 @@ dependencies = [ "dirs", "flate2", "futures", + "keyring", "pbkdf2", "rand 0.9.2", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "sha2", @@ -769,6 +783,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1057,6 +1092,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1627,6 +1674,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1642,6 +1698,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2188,6 +2253,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2236,6 +2316,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2258,6 +2347,17 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2414,7 +2514,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3530,6 +3630,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3581,7 +3695,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3608,7 +3722,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3718,6 +3832,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6286,6 +6413,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8fd3965..d6f86228 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,5 @@ url = "2" flate2 = "1" chacha20poly1305 = "0.10" pbkdf2 = "0.12" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 71b49c75..190a09d9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,9 +3,11 @@ pub mod account; pub mod oauth; pub mod process; +pub mod stats; pub mod usage; pub use account::*; pub use oauth::*; pub use process::*; +pub use stats::*; pub use usage::*; diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs new file mode 100644 index 00000000..96d72761 --- /dev/null +++ b/src-tauri/src/commands/stats.rs @@ -0,0 +1,739 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Duration, Local, NaiveDate, Timelike, Utc}; +use rusqlite::Connection; +use serde_json::Value; + +use crate::types::{ + CodexStats, DailyModelData, DailyOverviewData, HeatmapDay, ModelTokenBreakdown, ModelTotals, +}; + +#[derive(Default)] +struct DailyModelAccumulator { + input_tokens: u64, + output_tokens: u64, +} + +impl DailyModelAccumulator { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + } +} + +#[derive(Default)] +struct ModelAccumulator { + input_tokens: u64, + output_tokens: u64, +} + +impl ModelAccumulator { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + } +} + +#[derive(Default)] +struct StatsAccumulator { + sessions: u64, + messages: u64, + hour_counts: HashMap, + daily_activity: HashMap, + daily_sessions: HashMap, + daily_messages: HashMap, + daily_hour_counts: HashMap, + daily_model: HashMap>, + model_totals: HashMap, + active_dates: BTreeSet, +} + +impl StatsAccumulator { + fn is_empty(&self) -> bool { + self.sessions == 0 + && self.messages == 0 + && self.daily_model.is_empty() + && self.active_dates.is_empty() + } + + fn record_session(&mut self, date: NaiveDate, mark_activity: bool) { + self.sessions += 1; + *self.daily_sessions.entry(date).or_insert(0) += 1; + self.active_dates.insert(date); + + if mark_activity { + *self.daily_activity.entry(date).or_insert(0) += 1; + } + } + + fn record_message(&mut self, date: NaiveDate, hour: Option) { + self.messages += 1; + *self.daily_messages.entry(date).or_insert(0) += 1; + *self.daily_activity.entry(date).or_insert(0) += 1; + self.active_dates.insert(date); + + if let Some(hour) = hour.filter(|hour| *hour < 24) { + *self.hour_counts.entry(hour).or_insert(0) += 1; + self.daily_hour_counts.entry(date).or_insert([0; 24])[hour as usize] += 1; + } + } + + fn record_tokens( + &mut self, + date: NaiveDate, + model: String, + input_tokens: u64, + output_tokens: u64, + ) { + if input_tokens == 0 && output_tokens == 0 { + return; + } + + let entry = self + .daily_model + .entry(date) + .or_default() + .entry(model.clone()) + .or_default(); + entry.input_tokens += input_tokens; + entry.output_tokens += output_tokens; + + let total_entry = self.model_totals.entry(model).or_default(); + total_entry.input_tokens += input_tokens; + total_entry.output_tokens += output_tokens; + + self.active_dates.insert(date); + } + + fn merge(&mut self, other: StatsAccumulator) { + self.sessions += other.sessions; + self.messages += other.messages; + + for (hour, count) in other.hour_counts { + *self.hour_counts.entry(hour).or_insert(0) += count; + } + + for (date, count) in other.daily_activity { + *self.daily_activity.entry(date).or_insert(0) += count; + } + + for (date, count) in other.daily_sessions { + *self.daily_sessions.entry(date).or_insert(0) += count; + } + + for (date, count) in other.daily_messages { + *self.daily_messages.entry(date).or_insert(0) += count; + } + + for (date, buckets) in other.daily_hour_counts { + let entry = self.daily_hour_counts.entry(date).or_insert([0; 24]); + for (index, count) in buckets.into_iter().enumerate() { + entry[index] += count; + } + } + + for (date, models) in other.daily_model { + let day_entry = self.daily_model.entry(date).or_default(); + for (model, acc) in models { + let model_entry = day_entry.entry(model).or_default(); + model_entry.input_tokens += acc.input_tokens; + model_entry.output_tokens += acc.output_tokens; + } + } + + for (model, acc) in other.model_totals { + let total_entry = self.model_totals.entry(model).or_default(); + total_entry.input_tokens += acc.input_tokens; + total_entry.output_tokens += acc.output_tokens; + } + + self.active_dates.extend(other.active_dates); + } +} + +#[tauri::command] +pub async fn get_codex_stats() -> Result { + let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?; + let codex_dir = home.join(".codex"); + let sessions_root = codex_dir.join("sessions"); + let logs_path = codex_dir.join("logs_2.sqlite"); + let mut stats = StatsAccumulator::default(); + + if sessions_root.exists() { + stats.merge(load_session_history_stats(&sessions_root)?); + } + + if logs_path.exists() { + stats.merge(load_sqlite_stats(&logs_path)?); + } + + if stats.is_empty() { + return Ok(CodexStats::empty()); + } + + Ok(build_stats(stats)) +} + +fn load_session_history_stats(root: &Path) -> Result { + let mut session_files = Vec::new(); + collect_session_files(root, &mut session_files).map_err(|e| e.to_string())?; + session_files.sort(); + + if session_files.is_empty() { + return Ok(StatsAccumulator::default()); + } + + let mut stats = StatsAccumulator::default(); + + for path in session_files { + let Some(session_date) = session_date_from_path(&path) else { + continue; + }; + + stats.record_session(session_date, true); + ingest_session_file(&path, session_date, &mut stats); + } + + Ok(stats) +} + +fn ingest_session_file(path: &Path, session_date: NaiveDate, stats: &mut StatsAccumulator) { + let file = match File::open(path) { + Ok(file) => file, + Err(_) => return, + }; + + let reader = BufReader::new(file); + let mut current_model: Option = None; + let mut last_input_tokens = 0u64; + let mut last_output_tokens = 0u64; + + for line in reader.lines() { + let Ok(line) = line else { + continue; + }; + + if line.contains("\"type\":\"turn_context\"") { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + + current_model = value + .pointer("/payload/model") + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .map(str::to_string); + continue; + } + + if !line.contains("\"type\":\"event_msg\"") { + continue; + } + + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + + match value.pointer("/payload/type").and_then(Value::as_str) { + Some("user_message") => { + stats.record_message(session_date, extract_local_hour(&value)); + } + Some("token_count") => { + let Some((input_tokens, output_tokens)) = extract_total_token_usage(&value) else { + continue; + }; + + let delta_input = input_tokens.saturating_sub(last_input_tokens); + let delta_output = output_tokens.saturating_sub(last_output_tokens); + last_input_tokens = input_tokens; + last_output_tokens = output_tokens; + + let model = current_model + .clone() + .unwrap_or_else(|| "unknown".to_string()); + stats.record_tokens(session_date, model, delta_input, delta_output); + } + _ => {} + } + } +} + +fn load_sqlite_stats(logs_path: &Path) -> Result { + let conn = Connection::open(logs_path).map_err(|e| e.to_string())?; + + let mut stats = StatsAccumulator::default(); + let mut session_first_dates: HashMap = HashMap::new(); + let mut submission_ids: HashSet = HashSet::new(); + let mut completion_ids: HashSet = HashSet::new(); + + { + let mut stmt = conn + .prepare( + "SELECT ts, feedback_log_body + FROM logs + WHERE target = 'codex_otel.log_only' + AND feedback_log_body IS NOT NULL + AND feedback_log_body LIKE '%event.name=\"codex.sse_event\" event.kind=response.completed%' + ORDER BY ts ASC, ts_nanos ASC, id ASC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([], |row| { + let ts: i64 = row.get(0)?; + let body: String = row.get(1)?; + Ok((ts, body)) + }) + .map_err(|e| e.to_string())?; + + for row in rows { + let (ts, body) = row.map_err(|e| e.to_string())?; + let model = extract_value(&body, "model") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let conversation_id = extract_value(&body, "conversation.id"); + let input_tokens = extract_u64(&body, "input_token_count").unwrap_or(0); + let output_tokens = extract_u64(&body, "output_token_count").unwrap_or(0); + let dedupe_key = format!( + "{}|{}|{}|{}|{}", + extract_value(&body, "event.timestamp").unwrap_or_else(|| ts.to_string()), + conversation_id.clone().unwrap_or_default(), + model, + input_tokens, + output_tokens + ); + + if !completion_ids.insert(dedupe_key) { + continue; + } + + let event_time = extract_value(&body, "event.timestamp") + .and_then(parse_rfc3339_local) + .or_else(|| { + DateTime::::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&Local)) + }); + + let Some(event_time) = event_time else { + continue; + }; + + let date = event_time.date_naive(); + stats.active_dates.insert(date); + *stats.daily_activity.entry(date).or_insert(0) += 1; + stats.record_tokens(date, model, input_tokens, output_tokens); + + if let Some(sid) = conversation_id { + session_first_dates + .entry(sid) + .and_modify(|existing| { + if date < *existing { + *existing = date; + } + }) + .or_insert(date); + } + } + } + + for first_date in session_first_dates.into_values() { + stats.record_session(first_date, false); + } + + { + let mut stmt = conn + .prepare( + "SELECT ts, feedback_log_body + FROM logs + WHERE target = 'codex_otel.log_only' + AND feedback_log_body IS NOT NULL + AND feedback_log_body LIKE '%otel.name=\"op.dispatch.user_input\"%' + AND feedback_log_body LIKE '%submission.id=%' + ORDER BY ts ASC, ts_nanos ASC, id ASC", + ) + .map_err(|e| e.to_string())?; + + let rows = stmt + .query_map([], |row| { + let ts: i64 = row.get(0)?; + let body: String = row.get(1)?; + Ok((ts, body)) + }) + .map_err(|e| e.to_string())?; + + for row in rows { + let (ts, body) = row.map_err(|e| e.to_string())?; + let Some(submission_id) = extract_value(&body, "submission.id") else { + continue; + }; + if !submission_ids.insert(submission_id) { + continue; + } + + if let Some(event_time) = DateTime::::from_timestamp(ts, 0) { + let local_time = event_time.with_timezone(&Local); + stats.record_message(local_time.date_naive(), Some(local_time.hour() as u8)); + } + } + } + + Ok(stats) +} + +fn build_stats(stats: StatsAccumulator) -> CodexStats { + let StatsAccumulator { + sessions, + messages, + hour_counts, + daily_activity, + daily_sessions, + daily_messages, + daily_hour_counts, + daily_model, + model_totals, + active_dates, + } = stats; + + let mut heatmap: Vec = active_dates + .iter() + .map(|date| { + let token_total = daily_model + .get(date) + .map(|models| { + models + .values() + .map(DailyModelAccumulator::total_tokens) + .sum() + }) + .unwrap_or(0); + let fallback_activity = daily_activity.get(date).copied().unwrap_or(1); + + HeatmapDay { + date: date.to_string(), + count: if token_total > 0 { + token_total + } else { + fallback_activity + }, + } + }) + .collect(); + heatmap.sort_by(|a, b| a.date.cmp(&b.date)); + + let mut daily_overview_data: Vec = active_dates + .iter() + .map(|date| DailyOverviewData { + date: date.to_string(), + sessions: daily_sessions.get(date).copied().unwrap_or(0), + messages: daily_messages.get(date).copied().unwrap_or(0), + hourly_messages: daily_hour_counts + .get(date) + .copied() + .unwrap_or([0; 24]) + .to_vec(), + }) + .collect(); + daily_overview_data.sort_by(|a, b| a.date.cmp(&b.date)); + + let mut daily_model_data: Vec = daily_model + .into_iter() + .map(|(date, models)| { + let details = models + .iter() + .map(|(model, acc)| { + ( + model.clone(), + ModelTokenBreakdown { + input_tokens: acc.input_tokens, + output_tokens: acc.output_tokens, + total_tokens: acc.total_tokens(), + }, + ) + }) + .collect::>(); + + let combined = details + .iter() + .map(|(model, detail)| (model.clone(), detail.total_tokens)) + .collect::>(); + + DailyModelData { + date: date.to_string(), + models: combined, + details, + } + }) + .collect(); + daily_model_data.sort_by(|a, b| a.date.cmp(&b.date)); + + let grand_total: u64 = model_totals + .values() + .map(ModelAccumulator::total_tokens) + .sum(); + let mut model_totals: Vec = model_totals + .into_iter() + .map(|(model, acc)| { + let total_tokens = acc.total_tokens(); + ModelTotals { + model, + input_tokens: acc.input_tokens, + output_tokens: acc.output_tokens, + total_tokens, + percentage: if grand_total > 0 { + (total_tokens as f64 / grand_total as f64) * 100.0 + } else { + 0.0 + }, + } + }) + .collect(); + model_totals.sort_by(|a, b| { + b.total_tokens + .cmp(&a.total_tokens) + .then_with(|| a.model.cmp(&b.model)) + }); + + let today = Local::now().date_naive(); + let current_streak = current_streak(&active_dates, today); + let longest_streak = longest_streak(&active_dates); + let peak_hour = hour_counts + .into_iter() + .max_by_key(|(_, count)| *count) + .map(|(hour, _)| hour); + let favorite_model = model_totals.first().map(|item| item.model.clone()); + let total_input_tokens: u64 = model_totals.iter().map(|item| item.input_tokens).sum(); + let total_output_tokens: u64 = model_totals.iter().map(|item| item.output_tokens).sum(); + let total_tokens = total_input_tokens + total_output_tokens; + + CodexStats { + sessions, + messages, + total_input_tokens, + total_output_tokens, + total_tokens, + active_days: active_dates.len() as u64, + current_streak, + longest_streak, + peak_hour, + favorite_model, + heatmap, + daily_overview_data, + daily_model_data, + model_totals, + fun_fact: make_fun_fact(total_tokens), + } +} + +fn collect_session_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + collect_session_files(&path, out)?; + } else if file_type.is_file() + && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") + { + out.push(path); + } + } + + Ok(()) +} + +fn session_date_from_path(path: &Path) -> Option { + let day = path.parent()?.file_name()?.to_str()?.parse::().ok()?; + let month = path + .parent()? + .parent()? + .file_name()? + .to_str()? + .parse::() + .ok()?; + let year = path + .parent()? + .parent()? + .parent()? + .file_name()? + .to_str()? + .parse::() + .ok()?; + + NaiveDate::from_ymd_opt(year, month, day) +} + +fn extract_local_hour(value: &Value) -> Option { + let timestamp = value.get("timestamp")?.as_str()?; + let local_time = parse_rfc3339_local(timestamp)?; + Some(local_time.hour() as u8) +} + +fn extract_total_token_usage(value: &Value) -> Option<(u64, u64)> { + let usage = value.pointer("/payload/info/total_token_usage")?; + let input_tokens = usage.get("input_tokens")?.as_u64()?; + let output_tokens = usage.get("output_tokens")?.as_u64()?; + Some((input_tokens, output_tokens)) +} + +fn parse_rfc3339_local(value: impl AsRef) -> Option> { + DateTime::parse_from_rfc3339(value.as_ref()) + .ok() + .map(|dt| dt.with_timezone(&Local)) +} + +fn extract_value(body: &str, key: &str) -> Option { + let quoted_marker = format!("{key}=\""); + if let Some(start) = body.find("ed_marker) { + let value_start = start + quoted_marker.len(); + let value_end = body[value_start..].find('"')?; + return Some(body[value_start..value_start + value_end].to_string()); + } + + let marker = format!("{key}="); + let start = body.find(&marker)?; + let value_start = start + marker.len(); + let value = body[value_start..] + .split_whitespace() + .next()? + .trim_end_matches(',') + .trim_end_matches('}') + .trim_end_matches(']') + .to_string(); + Some(value) +} + +fn extract_u64(body: &str, key: &str) -> Option { + extract_value(body, key)?.parse().ok() +} + +fn current_streak(active_dates: &BTreeSet, today: NaiveDate) -> u64 { + let start = if active_dates.contains(&today) { + Some(today) + } else { + today + .checked_sub_signed(Duration::days(1)) + .filter(|date| active_dates.contains(date)) + }; + + let Some(mut day) = start else { + return 0; + }; + + let mut streak = 0u64; + loop { + if active_dates.contains(&day) { + streak += 1; + match day.checked_sub_signed(Duration::days(1)) { + Some(prev) => day = prev, + None => break, + } + } else { + break; + } + } + streak +} + +fn longest_streak(active_dates: &BTreeSet) -> u64 { + let mut longest = 0u64; + let mut run = 0u64; + let mut previous: Option = None; + + for &date in active_dates { + match previous { + Some(prev) if date == prev + Duration::days(1) => run += 1, + _ => run = 1, + } + longest = longest.max(run); + previous = Some(date); + } + + longest +} + +fn make_fun_fact(total: u64) -> Option { + if total == 0 { + return None; + } + + const BOOKS: &[(&str, u64)] = &[ + ("Animal Farm", 39_000), + ("The Great Gatsby", 74_000), + ("The Catcher in the Rye", 87_000), + ("Harry Potter and the Sorcerer's Stone", 118_000), + ("Dune", 268_000), + ("Moby Dick", 322_000), + ("War and Peace", 580_000), + ("The Bible", 783_000), + ("a complete Encyclopedia Britannica", 44_000_000), + ]; + + let best = BOOKS + .iter() + .rev() + .find(|(_, tokens)| total / tokens >= 2) + .or_else(|| BOOKS.iter().rev().find(|(_, tokens)| total >= *tokens)); + + match best { + Some((book, tokens)) => { + let mult = total / tokens; + Some(format!("You've used ~{}x more tokens than {}.", mult, book)) + } + None => { + let (book, tokens) = BOOKS[0]; + let pct = (total as f64 / tokens as f64 * 100.0) as u64; + Some(format!( + "You've processed {}% of the tokens in {}.", + pct, book + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + #[test] + fn build_stats_preserves_daily_overview_after_merging_sources() { + let mut session_history = StatsAccumulator::default(); + let day_one = date(2026, 4, 1); + session_history.record_session(day_one, true); + session_history.record_message(day_one, Some(10)); + session_history.record_message(day_one, Some(10)); + session_history.record_tokens(day_one, "gpt-5".to_string(), 120, 80); + + let mut sqlite = StatsAccumulator::default(); + let day_two = date(2026, 4, 2); + sqlite.record_session(day_two, false); + sqlite.record_message(day_two, Some(16)); + sqlite.record_tokens(day_two, "gpt-4.1".to_string(), 30, 20); + + session_history.merge(sqlite); + let stats = build_stats(session_history); + + assert_eq!(stats.sessions, 2); + assert_eq!(stats.messages, 3); + assert_eq!(stats.total_tokens, 250); + assert_eq!(stats.active_days, 2); + assert_eq!(stats.peak_hour, Some(10)); + assert_eq!(stats.daily_overview_data.len(), 2); + + let first_day = &stats.daily_overview_data[0]; + assert_eq!(first_day.date, "2026-04-01"); + assert_eq!(first_day.sessions, 1); + assert_eq!(first_day.messages, 2); + assert_eq!(first_day.hourly_messages[10], 2); + + let second_day = &stats.daily_overview_data[1]; + assert_eq!(second_day.date, "2026-04-02"); + assert_eq!(second_day.sessions, 1); + assert_eq!(second_day.messages, 1); + assert_eq!(second_day.hourly_messages[16], 1); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8c1f64f..bf46f5a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ pub mod web; use commands::{ add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, - get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, + get_codex_stats, get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, refresh_all_accounts_usage, rename_account, set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, }; @@ -52,6 +52,8 @@ pub fn run() { warmup_all_accounts, // Process detection check_codex_processes, + // Codex stats + get_codex_stats, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dc3d2cc5..1a78acf6 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1,5 +1,7 @@ //! Core types for Codex Switcher +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -302,3 +304,101 @@ pub struct CreditStatusDetails { #[serde(default)] pub balance: Option, } + +// ============================================================================ +// Codex usage stats +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelTokenBreakdown { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, +} + +/// Aggregated stats from Codex local telemetry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexStats { + pub sessions: u64, + pub messages: u64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_tokens: u64, + pub active_days: u64, + pub current_streak: u64, + pub longest_streak: u64, + /// Most common hour of day for user messages (0–23) + pub peak_hour: Option, + /// Model name with the most total tokens + pub favorite_model: Option, + /// Per-day token counts for the heatmap + pub heatmap: Vec, + /// Per-day session/message counts used for filtered overview cards + pub daily_overview_data: Vec, + /// Per-day, per-model token breakdowns for the chart + pub daily_model_data: Vec, + /// Aggregated per-model totals + pub model_totals: Vec, + pub fun_fact: Option, +} + +impl CodexStats { + pub fn empty() -> Self { + Self { + sessions: 0, + messages: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + active_days: 0, + current_streak: 0, + longest_streak: 0, + peak_hour: None, + favorite_model: None, + heatmap: Vec::new(), + daily_overview_data: Vec::new(), + daily_model_data: Vec::new(), + model_totals: Vec::new(), + fun_fact: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyOverviewData { + /// "YYYY-MM-DD" + pub date: String, + pub sessions: u64, + pub messages: u64, + /// Message counts for each hour bucket (0-23) + #[serde(default)] + pub hourly_messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeatmapDay { + /// "YYYY-MM-DD" + pub date: String, + /// Total tokens for the day + pub count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyModelData { + /// "YYYY-MM-DD" + pub date: String, + /// model name → total tokens + pub models: HashMap, + /// model name → exact token split for the day + #[serde(default)] + pub details: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelTotals { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub percentage: f64, +} diff --git a/src/App.tsx b/src/App.tsx index a6af12ea..9f7666a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useAccounts } from "./hooks/useAccounts"; -import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; +import { AccountCard, AddAccountModal, StatsModal, UpdateChecker } from "./components"; import type { CodexProcessInfo } from "./types"; import { exportFullBackupFile, @@ -36,6 +36,7 @@ function App() { } = useAccounts(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isStatsModalOpen, setIsStatsModalOpen] = useState(false); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [configModalMode, setConfigModalMode] = useState<"slim_export" | "slim_import">( "slim_export" @@ -477,6 +478,13 @@ function App() { )} + + ))} + + + +
+ {/* Time range */} +
+ {(["all", "30d", "7d"] as TimeRange[]).map((r) => ( + + ))} +
+ +
+ + + {/* Body */} +
+ {loading && ( +
+
+
+ )} + + {error && ( +
{error}
+ )} + + {!loading && !error && stats && ( + <> + {tab === "overview" && ( + + )} + {tab === "models" && ( + + )} + + )} +
+
+ + ); +} + +// ── Overview tab ────────────────────────────────────────────────────────────── + +function OverviewTab({ stats, range }: { stats: CodexStats; range: TimeRange }) { + const cutoff = cutoffDate(range); + const heatmapFiltered = cutoff + ? stats.heatmap.filter((d) => d.date >= cutoff) + : stats.heatmap; + + const filteredTotals = useMemo(() => { + if (!cutoff) { + return { + sessions: stats.sessions, + messages: stats.messages, + total_tokens: stats.total_tokens, + active_days: stats.active_days, + current_streak: stats.current_streak, + longest_streak: stats.longest_streak, + peak_hour: stats.peak_hour, + favorite_model: stats.favorite_model, + }; + } + + const overviewDays = stats.daily_overview_data.filter((day) => day.date >= cutoff); + const modelBreakdowns = accumulateBreakdowns(stats.daily_model_data, cutoff); + const tokenDays = stats.daily_model_data.filter((d) => d.date >= cutoff); + const activeDaysSet = new Set( + heatmapFiltered + .filter((day) => day.count > 0) + .map((day) => day.date) + ); + const total = tokenDays.reduce( + (s, d) => s + Object.values(d.models).reduce((a, b) => a + b, 0), + 0 + ); + const favoriteModel = Object.entries(modelBreakdowns) + .sort(([, a], [, b]) => b.total_tokens - a.total_tokens)[0]?.[0] ?? null; + + return { + sessions: overviewDays.reduce((sum, day) => sum + day.sessions, 0), + messages: overviewDays.reduce((sum, day) => sum + day.messages, 0), + total_tokens: total, + active_days: activeDaysSet.size, + current_streak: currentStreakForDates(activeDaysSet), + longest_streak: longestStreakForDates(Array.from(activeDaysSet).sort()), + peak_hour: peakHourForOverviewDays(overviewDays), + favorite_model: favoriteModel, + }; + }, [stats, cutoff, heatmapFiltered]); + + const cards = [ + { label: "Sessions", value: fmtNum(filteredTotals.sessions) }, + { label: "Messages", value: fmtNum(filteredTotals.messages) }, + { label: "Total tokens", value: fmtNum(filteredTotals.total_tokens) }, + { label: "Active days", value: String(filteredTotals.active_days) }, + { label: "Current streak", value: `${filteredTotals.current_streak}d` }, + { label: "Longest streak", value: `${filteredTotals.longest_streak}d` }, + { label: "Peak hour", value: fmtHour(filteredTotals.peak_hour) }, + { + label: "Favorite model", + value: filteredTotals.favorite_model + ? fmtModelName(filteredTotals.favorite_model) + : "—", + }, + ]; + + return ( +
+ {/* Stat cards grid */} +
+ {cards.map((c) => ( + + ))} +
+ + {/* Heatmap */} +
+ + {stats.fun_fact && ( +

+ {stats.fun_fact} +

+ )} +
+
+ ); +} + +// ── Models tab ──────────────────────────────────────────────────────────────── + +function ModelsTab({ + stats, + filteredTotals, + range, +}: { + stats: CodexStats; + filteredTotals: ModelTotals[]; + range: TimeRange; +}) { + const modelColors = useMemo(() => { + const map: Record = {}; + filteredTotals.forEach((m, i) => { + map[m.model] = BLUES[i % BLUES.length]; + }); + return map; + }, [filteredTotals]); + + return ( +
+ {/* Bar chart */} +
+ +
+ + {/* Model list */} +
+ {filteredTotals.map((m) => ( +
+ + + {fmtModelName(m.model)} + + + {fmtTokens(m.input_tokens)} in · {fmtTokens(m.output_tokens)} out + + + {m.percentage.toFixed(1)}% + +
+ ))} + {filteredTotals.length === 0 && ( +

+ No model data for this period +

+ )} +
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 748a2107..5283eb90 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export { AccountCard } from "./AccountCard"; export { UsageBar } from "./UsageBar"; export { AddAccountModal } from "./AddAccountModal"; export { UpdateChecker } from "./UpdateChecker"; +export { StatsModal } from "./StatsModal"; diff --git a/src/types/index.ts b/src/types/index.ts index 6b2110be..b0400980 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,3 +56,55 @@ export interface ImportAccountsSummary { imported_count: number; skipped_count: number; } + +// ── Codex usage stats ──────────────────────────────────────────────────────── + +export interface ModelTokenBreakdown { + input_tokens: number; + output_tokens: number; + total_tokens: number; +} + +export interface HeatmapDay { + date: string; // "YYYY-MM-DD" + count: number; +} + +export interface DailyOverviewData { + date: string; // "YYYY-MM-DD" + sessions: number; + messages: number; + hourly_messages: number[]; // 24 hourly buckets +} + +export interface DailyModelData { + date: string; // "YYYY-MM-DD" + models: Record; // model → total tokens + details: Record; // model → exact token split +} + +export interface ModelTotals { + model: string; + input_tokens: number; + output_tokens: number; + total_tokens: number; + percentage: number; +} + +export interface CodexStats { + sessions: number; + messages: number; + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; + active_days: number; + current_streak: number; + longest_streak: number; + peak_hour: number | null; + favorite_model: string | null; + heatmap: HeatmapDay[]; + daily_overview_data: DailyOverviewData[]; + daily_model_data: DailyModelData[]; + model_totals: ModelTotals[]; + fun_fact: string | null; +}