From b91e22dbce2b7ee054d22823b949ddf820f4c5a5 Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Sun, 19 Apr 2026 11:49:19 -0400 Subject: [PATCH 1/6] feat: add Claude Code usage stats panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Stats button to the header that opens a modal reading and aggregating data from all ~/.claude/projects/**/*.jsonl conversation files across every account that has ever been used. Overview tab: - 8 stat cards: sessions, messages, total tokens, active days, current/longest streak, peak hour, favorite model - GitHub-style heatmap calendar with blue intensity levels (quartile-based) - Fun book-comparison fact (e.g. "You've used ~364× more tokens than Animal Farm.") Models tab: - SVG stacked bar chart showing per-day token usage broken down by model - Model list with input/output token counts and percentage share Time filter (All / 30d / 7d) re-derives all metrics from the daily data so every panel stays consistent when switching ranges. Rust: new `get_claude_stats` Tauri command in src-tauri/src/commands/stats.rs that line-by-line parses JSONL entries (user messages, assistant usage blocks) and returns ClaudeStats including heatmap days, per-model daily breakdowns, and aggregated totals. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/stats.rs | 332 +++++++++++++++++ src-tauri/src/lib.rs | 4 +- src-tauri/src/types.rs | 76 ++++ src/App.tsx | 16 +- src/components/StatsModal.tsx | 639 ++++++++++++++++++++++++++++++++ src/components/index.ts | 1 + src/types/index.ts | 37 ++ 8 files changed, 1105 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/stats.rs create mode 100644 src/components/StatsModal.tsx 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..ad4306c0 --- /dev/null +++ b/src-tauri/src/commands/stats.rs @@ -0,0 +1,332 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader}; + +use chrono::{DateTime, Duration, NaiveDate, Timelike, Utc}; +use serde::Deserialize; + +use crate::types::{ClaudeStats, DailyModelData, HeatmapDay, ModelTotals}; + +// ── Minimal JSONL structures ────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct RawEntry { + #[serde(rename = "type")] + entry_type: Option, + #[serde(rename = "sessionId")] + session_id: Option, + timestamp: Option, + message: Option, +} + +#[derive(Deserialize)] +struct RawMessage { + role: Option, + model: Option, + usage: Option, +} + +#[derive(Deserialize, Default)] +struct RawUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + cache_creation_input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, +} + +// ── Command ─────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn get_claude_stats() -> Result { + let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?; + let projects_dir = home.join(".claude").join("projects"); + + if !projects_dir.exists() { + return Ok(ClaudeStats::empty()); + } + + let mut session_ids: HashSet = HashSet::new(); + let mut message_count = 0u64; + let mut hour_counts: HashMap = HashMap::new(); + + // date → model → total_tokens + let mut daily_model: HashMap> = HashMap::new(); + // model → (input, output) + let mut model_input: HashMap = HashMap::new(); + let mut model_output: HashMap = HashMap::new(); + // all dates with any activity + let mut active_dates: BTreeSet = BTreeSet::new(); + + let Ok(project_entries) = fs::read_dir(&projects_dir) else { + return Ok(ClaudeStats::empty()); + }; + + for project_entry in project_entries.flatten() { + let project_path = project_entry.path(); + if !project_path.is_dir() { + continue; + } + + let Ok(file_entries) = fs::read_dir(&project_path) else { + continue; + }; + + for file_entry in file_entries.flatten() { + let file_path = file_entry.path(); + if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + + let Ok(file) = fs::File::open(&file_path) else { + continue; + }; + let reader = BufReader::new(file); + + for line in reader.lines().flatten() { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + let Ok(entry) = serde_json::from_str::(&line) else { + continue; + }; + + if let Some(sid) = &entry.session_id { + session_ids.insert(sid.clone()); + } + + let time_info = entry.timestamp.as_ref().and_then(|ts| { + DateTime::parse_from_rfc3339(ts).ok().map(|dt| { + let utc: DateTime = dt.into(); + (utc.date_naive(), utc.hour() as u8) + }) + }); + + if let Some((date, _)) = time_info { + active_dates.insert(date); + } + + match entry.entry_type.as_deref() { + Some("user") => { + message_count += 1; + if let Some((_, hour)) = time_info { + *hour_counts.entry(hour).or_insert(0) += 1; + } + } + // assistant entries may have no outer "type" field + Some("assistant") | None => { + if let Some(msg) = &entry.message { + if msg.role.as_deref() == Some("assistant") { + if let Some(usage) = &msg.usage { + let inp = usage.input_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens; + let out = usage.output_tokens; + + if let Some(model) = &msg.model { + *model_input.entry(model.clone()).or_insert(0) += inp; + *model_output.entry(model.clone()).or_insert(0) += out; + + if let Some((date, _)) = time_info { + *daily_model + .entry(date) + .or_default() + .entry(model.clone()) + .or_insert(0) += inp + out; + } + } + } + } + } + } + _ => {} + } + } + } + } + + // ── Heatmap (daily token totals) ───────────────────────────────────────── + let heatmap: Vec = { + let mut day_tokens: HashMap = HashMap::new(); + for (date, models) in &daily_model { + let total: u64 = models.values().sum(); + *day_tokens.entry(*date).or_insert(0) += total; + } + let mut v: Vec = day_tokens + .into_iter() + .map(|(date, count)| HeatmapDay { + date: date.to_string(), + count, + }) + .collect(); + v.sort_by(|a, b| a.date.cmp(&b.date)); + v + }; + + // ── Daily model data (for bar chart) ───────────────────────────────────── + let daily_model_data: Vec = { + let mut v: Vec = daily_model + .into_iter() + .map(|(date, models)| DailyModelData { + date: date.to_string(), + models, + }) + .collect(); + v.sort_by(|a, b| a.date.cmp(&b.date)); + v + }; + + // ── Per-model aggregates ───────────────────────────────────────────────── + let grand_total: u64 = model_input.values().sum::() + model_output.values().sum::(); + let model_totals: Vec = { + let mut all_models: Vec = model_input.keys().cloned().collect(); + all_models.sort(); + let mut v: Vec = all_models + .into_iter() + .map(|model| { + let inp = *model_input.get(&model).unwrap_or(&0); + let out = *model_output.get(&model).unwrap_or(&0); + let total = inp + out; + ModelTotals { + model, + input_tokens: inp, + output_tokens: out, + total_tokens: total, + percentage: if grand_total > 0 { + (total as f64 / grand_total as f64) * 100.0 + } else { + 0.0 + }, + } + }) + .collect(); + v.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + v + }; + + // ── Streaks ────────────────────────────────────────────────────────────── + let today = Utc::now().date_naive(); + + let current_streak: u64 = { + let start = if active_dates.contains(&today) { + Some(today) + } else { + today + .checked_sub_signed(Duration::days(1)) + .filter(|d| active_dates.contains(d)) + }; + + match start { + None => 0, + Some(mut day) => { + 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 + } + } + }; + + let longest_streak: u64 = { + let mut max = 0u64; + let mut run = 0u64; + let mut prev: Option = None; + for &date in &active_dates { + match prev { + Some(p) if date == p + Duration::days(1) => run += 1, + _ => run = 1, + } + if run > max { + max = run; + } + prev = Some(date); + } + max + }; + + // ── Derived scalars ────────────────────────────────────────────────────── + let peak_hour = hour_counts + .into_iter() + .max_by_key(|(_, c)| *c) + .map(|(h, _)| h); + + let favorite_model = model_totals.first().map(|m| m.model.clone()); + let active_days = active_dates.len() as u64; + let total_input_tokens: u64 = model_input.values().sum(); + let total_output_tokens: u64 = model_output.values().sum(); + let total_tokens = total_input_tokens + total_output_tokens; + let fun_fact = make_fun_fact(total_tokens); + + Ok(ClaudeStats { + sessions: session_ids.len() as u64, + messages: message_count, + total_input_tokens, + total_output_tokens, + total_tokens, + active_days, + current_streak, + longest_streak, + peak_hour, + favorite_model, + heatmap, + daily_model_data, + model_totals, + fun_fact, + }) +} + +// ── Fun-fact helper ─────────────────────────────────────────────────────────── + +fn make_fun_fact(total: u64) -> Option { + if total == 0 { + return None; + } + // Approximate token counts for well-known texts + 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), + ]; + + // Pick the largest book whose multiplier ≥ 2 + 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 ~{}× 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 + )) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8c1f64f..0f03952b 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_claude_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, + // Claude Code stats + get_claude_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..afd8bd23 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,77 @@ pub struct CreditStatusDetails { #[serde(default)] pub balance: Option, } + +// ============================================================================ +// Claude Code usage stats +// ============================================================================ + +/// Aggregated stats from ~/.claude/projects JSONL files +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeStats { + 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, 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 ClaudeStats { + 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_model_data: Vec::new(), + model_totals: Vec::new(), + fun_fact: None, + } + } +} + +#[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, +} + +#[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..60cc9644 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: ClaudeStats; range: TimeRange }) { + // For filtered ranges, recalculate the scalar stats from daily data + 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, + }; + } + // Re-derive from daily data for filtered ranges + const days = stats.daily_model_data.filter((d) => d.date >= cutoff); + const activeDaysSet = new Set(days.map((d) => d.date)); + const total = days.reduce( + (s, d) => s + Object.values(d.models).reduce((a, b) => a + b, 0), + 0 + ); + // Streaks within filtered range + const sortedDates = Array.from(activeDaysSet).sort(); + let curStreak = 0, longestStreak = 0, streak = 0; + let prev = ""; + for (const d of sortedDates) { + if (prev) { + const diff = (new Date(d).getTime() - new Date(prev).getTime()) / 86400000; + streak = diff === 1 ? streak + 1 : 1; + } else { + streak = 1; + } + if (streak > longestStreak) longestStreak = streak; + prev = d; + } + const today = isoDate(new Date()); + const yesterday = isoDate(new Date(Date.now() - 86400000)); + curStreak = 0; + let checkDay = activeDaysSet.has(today) ? today : activeDaysSet.has(yesterday) ? yesterday : null; + while (checkDay && activeDaysSet.has(checkDay)) { + curStreak++; + const prev2 = isoDate(new Date(new Date(checkDay).getTime() - 86400000)); + checkDay = prev2; + } + return { + sessions: stats.sessions, + messages: stats.messages, + total_tokens: total, + active_days: activeDaysSet.size, + current_streak: curStreak, + longest_streak: longestStreak, + peak_hour: stats.peak_hour, + favorite_model: stats.favorite_model, + }; + }, [stats, cutoff]); + + 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: ClaudeStats; + 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) => ( +
+ + + {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..aeadc97c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,3 +56,40 @@ export interface ImportAccountsSummary { imported_count: number; skipped_count: number; } + +// ── Claude Code usage stats ─────────────────────────────────────────────────── + +export interface HeatmapDay { + date: string; // "YYYY-MM-DD" + count: number; +} + +export interface DailyModelData { + date: string; // "YYYY-MM-DD" + models: Record; // model → total tokens +} + +export interface ModelTotals { + model: string; + input_tokens: number; + output_tokens: number; + total_tokens: number; + percentage: number; +} + +export interface ClaudeStats { + 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_model_data: DailyModelData[]; + model_totals: ModelTotals[]; + fun_fact: string | null; +} From 6901fe5ec00f06129fc70c3c7692d305a47d1caf Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Sun, 19 Apr 2026 12:29:43 -0400 Subject: [PATCH 2/6] fix: derive stats from codex telemetry --- src-tauri/Cargo.lock | 147 +++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/stats.rs | 503 +++++++++++++++++--------------- src-tauri/src/types.rs | 14 +- src/App.tsx | 2 +- src/components/StatsModal.tsx | 93 +++--- src/types/index.ts | 9 +- 7 files changed, 498 insertions(+), 272 deletions(-) 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/stats.rs b/src-tauri/src/commands/stats.rs index ad4306c0..edcab602 100644 --- a/src-tauri/src/commands/stats.rs +++ b/src-tauri/src/commands/stats.rs @@ -1,275 +1,247 @@ use std::collections::{BTreeSet, HashMap, HashSet}; -use std::fs; -use std::io::{BufRead, BufReader}; use chrono::{DateTime, Duration, NaiveDate, Timelike, Utc}; -use serde::Deserialize; +use rusqlite::Connection; -use crate::types::{ClaudeStats, DailyModelData, HeatmapDay, ModelTotals}; +use crate::types::{ClaudeStats, DailyModelData, HeatmapDay, ModelTokenBreakdown, ModelTotals}; -// ── Minimal JSONL structures ────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct RawEntry { - #[serde(rename = "type")] - entry_type: Option, - #[serde(rename = "sessionId")] - session_id: Option, - timestamp: Option, - message: Option, +#[derive(Default)] +struct DailyModelAccumulator { + input_tokens: u64, + output_tokens: u64, } -#[derive(Deserialize)] -struct RawMessage { - role: Option, - model: Option, - usage: Option, +impl DailyModelAccumulator { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + } } -#[derive(Deserialize, Default)] -struct RawUsage { - #[serde(default)] +#[derive(Default)] +struct ModelAccumulator { input_tokens: u64, - #[serde(default)] output_tokens: u64, - #[serde(default)] - cache_creation_input_tokens: u64, - #[serde(default)] - cache_read_input_tokens: u64, } -// ── Command ─────────────────────────────────────────────────────────────────── +impl ModelAccumulator { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + } +} #[tauri::command] pub async fn get_claude_stats() -> Result { let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?; - let projects_dir = home.join(".claude").join("projects"); + let logs_path = home.join(".codex").join("logs_2.sqlite"); - if !projects_dir.exists() { + if !logs_path.exists() { return Ok(ClaudeStats::empty()); } + let conn = Connection::open(&logs_path).map_err(|e| e.to_string())?; + let mut session_ids: HashSet = HashSet::new(); + let mut submission_ids: HashSet = HashSet::new(); + let mut completion_ids: HashSet = HashSet::new(); let mut message_count = 0u64; let mut hour_counts: HashMap = HashMap::new(); - - // date → model → total_tokens - let mut daily_model: HashMap> = HashMap::new(); - // model → (input, output) - let mut model_input: HashMap = HashMap::new(); - let mut model_output: HashMap = HashMap::new(); - // all dates with any activity + let mut daily_model: HashMap> = + HashMap::new(); + let mut model_totals_acc: HashMap = HashMap::new(); let mut active_dates: BTreeSet = BTreeSet::new(); - let Ok(project_entries) = fs::read_dir(&projects_dir) else { - return Ok(ClaudeStats::empty()); - }; - - for project_entry in project_entries.flatten() { - let project_path = project_entry.path(); - if !project_path.is_dir() { - continue; - } - - let Ok(file_entries) = fs::read_dir(&project_path) else { - continue; - }; - - for file_entry in file_entries.flatten() { - let file_path = file_entry.path(); - if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + { + 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 Ok(file) = fs::File::open(&file_path) else { + if let Some(sid) = conversation_id { + session_ids.insert(sid); + } + + let event_time = extract_value(&body, "event.timestamp") + .and_then(|value| { + DateTime::parse_from_rfc3339(&value) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) + .or_else(|| DateTime::::from_timestamp(ts, 0)); + + let Some(event_time) = event_time else { continue; }; - let reader = BufReader::new(file); - - for line in reader.lines().flatten() { - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - - let Ok(entry) = serde_json::from_str::(&line) else { - continue; - }; - - if let Some(sid) = &entry.session_id { - session_ids.insert(sid.clone()); - } - - let time_info = entry.timestamp.as_ref().and_then(|ts| { - DateTime::parse_from_rfc3339(ts).ok().map(|dt| { - let utc: DateTime = dt.into(); - (utc.date_naive(), utc.hour() as u8) - }) - }); - - if let Some((date, _)) = time_info { - active_dates.insert(date); - } - - match entry.entry_type.as_deref() { - Some("user") => { - message_count += 1; - if let Some((_, hour)) = time_info { - *hour_counts.entry(hour).or_insert(0) += 1; - } - } - // assistant entries may have no outer "type" field - Some("assistant") | None => { - if let Some(msg) = &entry.message { - if msg.role.as_deref() == Some("assistant") { - if let Some(usage) = &msg.usage { - let inp = usage.input_tokens - + usage.cache_creation_input_tokens - + usage.cache_read_input_tokens; - let out = usage.output_tokens; - - if let Some(model) = &msg.model { - *model_input.entry(model.clone()).or_insert(0) += inp; - *model_output.entry(model.clone()).or_insert(0) += out; - - if let Some((date, _)) = time_info { - *daily_model - .entry(date) - .or_default() - .entry(model.clone()) - .or_insert(0) += inp + out; - } - } - } - } - } - } - _ => {} - } - } - } - } - // ── Heatmap (daily token totals) ───────────────────────────────────────── - let heatmap: Vec = { - let mut day_tokens: HashMap = HashMap::new(); - for (date, models) in &daily_model { - let total: u64 = models.values().sum(); - *day_tokens.entry(*date).or_insert(0) += total; - } - let mut v: Vec = day_tokens - .into_iter() - .map(|(date, count)| HeatmapDay { - date: date.to_string(), - count, - }) - .collect(); - v.sort_by(|a, b| a.date.cmp(&b.date)); - v - }; + let date = event_time.date_naive(); + active_dates.insert(date); - // ── Daily model data (for bar chart) ───────────────────────────────────── - let daily_model_data: Vec = { - let mut v: Vec = daily_model - .into_iter() - .map(|(date, models)| DailyModelData { - date: date.to_string(), - models, - }) - .collect(); - v.sort_by(|a, b| a.date.cmp(&b.date)); - v - }; + let entry = daily_model + .entry(date) + .or_default() + .entry(model.clone()) + .or_default(); + entry.input_tokens += input_tokens; + entry.output_tokens += output_tokens; + + let total_entry = model_totals_acc.entry(model).or_default(); + total_entry.input_tokens += input_tokens; + total_entry.output_tokens += output_tokens; + } + } - // ── Per-model aggregates ───────────────────────────────────────────────── - let grand_total: u64 = model_input.values().sum::() + model_output.values().sum::(); - let model_totals: Vec = { - let mut all_models: Vec = model_input.keys().cloned().collect(); - all_models.sort(); - let mut v: Vec = all_models - .into_iter() - .map(|model| { - let inp = *model_input.get(&model).unwrap_or(&0); - let out = *model_output.get(&model).unwrap_or(&0); - let total = inp + out; - ModelTotals { - model, - input_tokens: inp, - output_tokens: out, - total_tokens: total, - percentage: if grand_total > 0 { - (total as f64 / grand_total as f64) * 100.0 - } else { - 0.0 - }, - } + { + 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)) }) - .collect(); - v.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); - v - }; + .map_err(|e| e.to_string())?; - // ── Streaks ────────────────────────────────────────────────────────────── - let today = Utc::now().date_naive(); + 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; + } + message_count += 1; - let current_streak: u64 = { - let start = if active_dates.contains(&today) { - Some(today) - } else { - today - .checked_sub_signed(Duration::days(1)) - .filter(|d| active_dates.contains(d)) - }; - - match start { - None => 0, - Some(mut day) => { - 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 + if let Some(event_time) = DateTime::::from_timestamp(ts, 0) { + *hour_counts.entry(event_time.hour() as u8).or_insert(0) += 1; } } - }; + } - let longest_streak: u64 = { - let mut max = 0u64; - let mut run = 0u64; - let mut prev: Option = None; - for &date in &active_dates { - match prev { - Some(p) if date == p + Duration::days(1) => run += 1, - _ => run = 1, + let mut heatmap: Vec = daily_model + .iter() + .map(|(date, models)| HeatmapDay { + date: date.to_string(), + count: models + .values() + .map(DailyModelAccumulator::total_tokens) + .sum(), + }) + .collect(); + heatmap.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, } - if run > max { - max = run; + }) + .collect(); + daily_model_data.sort_by(|a, b| a.date.cmp(&b.date)); + + let grand_total: u64 = model_totals_acc + .values() + .map(ModelAccumulator::total_tokens) + .sum(); + let mut model_totals: Vec = model_totals_acc + .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 + }, } - prev = Some(date); - } - max - }; + }) + .collect(); + model_totals.sort_by(|a, b| { + b.total_tokens + .cmp(&a.total_tokens) + .then_with(|| a.model.cmp(&b.model)) + }); - // ── Derived scalars ────────────────────────────────────────────────────── + let today = Utc::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(|(_, c)| *c) - .map(|(h, _)| h); - - let favorite_model = model_totals.first().map(|m| m.model.clone()); - let active_days = active_dates.len() as u64; - let total_input_tokens: u64 = model_input.values().sum(); - let total_output_tokens: u64 = model_output.values().sum(); + .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; - let fun_fact = make_fun_fact(total_tokens); Ok(ClaudeStats { sessions: session_ids.len() as u64, @@ -277,7 +249,7 @@ pub async fn get_claude_stats() -> Result { total_input_tokens, total_output_tokens, total_tokens, - active_days, + active_days: active_dates.len() as u64, current_streak, longest_streak, peak_hour, @@ -285,17 +257,85 @@ pub async fn get_claude_stats() -> Result { heatmap, daily_model_data, model_totals, - fun_fact, + fun_fact: make_fun_fact(total_tokens), }) } -// ── Fun-fact helper ─────────────────────────────────────────────────────────── +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; } - // Approximate token counts for well-known texts + const BOOKS: &[(&str, u64)] = &[ ("Animal Farm", 39_000), ("The Great Gatsby", 74_000), @@ -308,7 +348,6 @@ fn make_fun_fact(total: u64) -> Option { ("a complete Encyclopedia Britannica", 44_000_000), ]; - // Pick the largest book whose multiplier ≥ 2 let best = BOOKS .iter() .rev() @@ -318,7 +357,7 @@ fn make_fun_fact(total: u64) -> Option { match best { Some((book, tokens)) => { let mult = total / tokens; - Some(format!("You've used ~{}× more tokens than {}.", mult, book)) + Some(format!("You've used ~{}x more tokens than {}.", mult, book)) } None => { let (book, tokens) = BOOKS[0]; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index afd8bd23..8ac2709c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -306,10 +306,17 @@ pub struct CreditStatusDetails { } // ============================================================================ -// Claude Code usage stats +// Codex usage stats // ============================================================================ -/// Aggregated stats from ~/.claude/projects JSONL files +#[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 ClaudeStats { pub sessions: u64, @@ -368,6 +375,9 @@ pub struct DailyModelData { 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)] diff --git a/src/App.tsx b/src/App.tsx index 60cc9644..9f7666a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -481,7 +481,7 @@ function App() { diff --git a/src/components/StatsModal.tsx b/src/components/StatsModal.tsx index b539c185..d7643861 100644 --- a/src/components/StatsModal.tsx +++ b/src/components/StatsModal.tsx @@ -1,5 +1,10 @@ import { useState, useEffect, useMemo } from "react"; -import type { ClaudeStats, DailyModelData, ModelTotals } from "../types"; +import type { + ClaudeStats, + DailyModelData, + ModelTokenBreakdown, + ModelTotals, +} from "../types"; import { invokeBackend } from "../lib/platform"; // ── Colour palette (7 blues, darkest first) ─────────────────────────────────── @@ -35,14 +40,41 @@ function fmtHour(h: number | null): string { return `${h - 12} PM`; } -function fmtModelName(m: string): string { - // "claude-sonnet-4-6" → "Sonnet 4.6" - const match = m.match(/claude-([a-z]+)-(\d+)-(\d+)/); - if (match) { - const [, family, maj, min] = match; +function fmtModelName(model: string): string { + const claudeMatch = model.match(/^claude-([a-z]+)-(\d+)-(\d+)/); + if (claudeMatch) { + const [, family, maj, min] = claudeMatch; return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${maj}.${min}`; } - return m; + + if (model.startsWith("gpt-")) { + return model.toUpperCase(); + } + + return model; +} + +function accumulateBreakdowns( + dailyData: DailyModelData[], + cutoff: string | null +): Record { + const relevantDays = cutoff + ? dailyData.filter((day) => day.date >= cutoff) + : dailyData; + const totals: Record = {}; + + for (const day of relevantDays) { + for (const [model, detail] of Object.entries(day.details)) { + if (!totals[model]) { + totals[model] = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + } + totals[model].input_tokens += detail.input_tokens; + totals[model].output_tokens += detail.output_tokens; + totals[model].total_tokens += detail.total_tokens; + } + } + + return totals; } function isoDate(d: Date): string { @@ -356,33 +388,24 @@ export function StatsModal({ isOpen, onClose }: StatsModalProps) { .finally(() => setLoading(false)); }, [isOpen]); - // Filter model totals + daily data by time range const filteredModelTotals = useMemo(() => { if (!stats) return []; - if (range === "all") return stats.model_totals; - const cutoff = cutoffDate(range)!; - const relevantDays = stats.daily_model_data.filter((d) => d.date >= cutoff); - const totals: Record = {}; - for (const day of relevantDays) { - for (const [model, tokens] of Object.entries(day.models)) { - if (!totals[model]) totals[model] = { inp: 0, out: 0 }; - // We only store combined in daily_model_data; use original ratio from model_totals - const mt = stats.model_totals.find((m) => m.model === model); - if (mt && mt.total_tokens > 0) { - const ratio = mt.input_tokens / mt.total_tokens; - totals[model].inp += Math.round(tokens * ratio); - totals[model].out += Math.round(tokens * (1 - ratio)); - } - } - } - const grand = Object.values(totals).reduce((a, v) => a + v.inp + v.out, 0); + const totals = accumulateBreakdowns( + stats.daily_model_data, + cutoffDate(range) + ); + const grand = Object.values(totals).reduce( + (sum, detail) => sum + detail.total_tokens, + 0 + ); + return Object.entries(totals) - .map(([model, { inp, out }]) => ({ + .map(([model, detail]) => ({ model, - input_tokens: inp, - output_tokens: out, - total_tokens: inp + out, - percentage: grand > 0 ? ((inp + out) / grand) * 100 : 0, + input_tokens: detail.input_tokens, + output_tokens: detail.output_tokens, + total_tokens: detail.total_tokens, + percentage: grand > 0 ? (detail.total_tokens / grand) * 100 : 0, })) .sort((a, b) => b.total_tokens - a.total_tokens); }, [stats, range]); @@ -496,7 +519,7 @@ function OverviewTab({ stats, range }: { stats: ClaudeStats; range: TimeRange }) favorite_model: stats.favorite_model, }; } - // Re-derive from daily data for filtered ranges + const modelBreakdowns = accumulateBreakdowns(stats.daily_model_data, cutoff); const days = stats.daily_model_data.filter((d) => d.date >= cutoff); const activeDaysSet = new Set(days.map((d) => d.date)); const total = days.reduce( @@ -526,6 +549,10 @@ function OverviewTab({ stats, range }: { stats: ClaudeStats; range: TimeRange }) const prev2 = isoDate(new Date(new Date(checkDay).getTime() - 86400000)); checkDay = prev2; } + + const favoriteModel = Object.entries(modelBreakdowns) + .sort(([, a], [, b]) => b.total_tokens - a.total_tokens)[0]?.[0] ?? null; + return { sessions: stats.sessions, messages: stats.messages, @@ -534,7 +561,7 @@ function OverviewTab({ stats, range }: { stats: ClaudeStats; range: TimeRange }) current_streak: curStreak, longest_streak: longestStreak, peak_hour: stats.peak_hour, - favorite_model: stats.favorite_model, + favorite_model: favoriteModel, }; }, [stats, cutoff]); @@ -618,7 +645,7 @@ function ModelsTab({ style={{ background: modelColors[m.model] ?? BLUES[6] }} /> - {m.model} + {fmtModelName(m.model)} {fmtTokens(m.input_tokens)} in · {fmtTokens(m.output_tokens)} out diff --git a/src/types/index.ts b/src/types/index.ts index aeadc97c..f85d04fe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,7 +57,13 @@ export interface ImportAccountsSummary { skipped_count: number; } -// ── Claude Code usage stats ─────────────────────────────────────────────────── +// ── Codex usage stats ──────────────────────────────────────────────────────── + +export interface ModelTokenBreakdown { + input_tokens: number; + output_tokens: number; + total_tokens: number; +} export interface HeatmapDay { date: string; // "YYYY-MM-DD" @@ -67,6 +73,7 @@ export interface HeatmapDay { export interface DailyModelData { date: string; // "YYYY-MM-DD" models: Record; // model → total tokens + details: Record; // model → exact token split } export interface ModelTotals { From 53c72abefb856daf70039fc9e6291bcff98e7036 Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Sun, 19 Apr 2026 12:40:09 -0400 Subject: [PATCH 3/6] fix: show recent heatmap activity by default --- src/components/StatsModal.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/StatsModal.tsx b/src/components/StatsModal.tsx index d7643861..e92037ba 100644 --- a/src/components/StatsModal.tsx +++ b/src/components/StatsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import type { ClaudeStats, DailyModelData, @@ -54,6 +54,10 @@ function fmtModelName(model: string): string { return model; } +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + function accumulateBreakdowns( dailyData: DailyModelData[], cutoff: string | null @@ -78,7 +82,10 @@ function accumulateBreakdowns( } function isoDate(d: Date): string { - return d.toISOString().slice(0, 10); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; } function cutoffDate(range: TimeRange): string | null { @@ -94,7 +101,8 @@ type TabId = "overview" | "models"; // ── Heatmap calendar ───────────────────────────────────────────────────────── function HeatmapCalendar({ heatmap }: { heatmap: ClaudeStats["heatmap"] }) { - const today = new Date(); + const today = startOfLocalDay(new Date()); + const scrollRef = useRef(null); // Start from the Sunday 52 full weeks back const start = new Date(today); start.setDate(start.getDate() - start.getDay() - 52 * 7); @@ -114,6 +122,12 @@ function HeatmapCalendar({ heatmap }: { heatmap: ClaudeStats["heatmap"] }) { const q2 = sorted[Math.floor(sorted.length * 0.5)] ?? 1; const q3 = sorted[Math.floor(sorted.length * 0.75)] ?? 1; + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollLeft = el.scrollWidth - el.clientWidth; + }, [heatmap]); + function level(count: number): number { if (count === 0) return 0; if (count <= q1) return 1; @@ -145,7 +159,7 @@ function HeatmapCalendar({ heatmap }: { heatmap: ClaudeStats["heatmap"] }) { } return ( -
+
{weeks.map((week, wi) => (
From d2ba1c43ac6197320b2d2b1e8aed0302e1fbfb95 Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Sun, 19 Apr 2026 13:02:16 -0400 Subject: [PATCH 4/6] fix: load stats from full codex session history --- src-tauri/src/commands/stats.rs | 307 +++++++++++++++++++++++++++++--- 1 file changed, 284 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs index edcab602..2024c062 100644 --- a/src-tauri/src/commands/stats.rs +++ b/src-tauri/src/commands/stats.rs @@ -1,7 +1,11 @@ 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, NaiveDate, Timelike, Utc}; +use chrono::{DateTime, Duration, Local, NaiveDate, Timelike, Utc}; use rusqlite::Connection; +use serde_json::Value; use crate::types::{ClaudeStats, DailyModelData, HeatmapDay, ModelTokenBreakdown, ModelTotals}; @@ -32,19 +36,180 @@ impl ModelAccumulator { #[tauri::command] pub async fn get_claude_stats() -> Result { let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?; - let logs_path = home.join(".codex").join("logs_2.sqlite"); + let codex_dir = home.join(".codex"); + let sessions_root = codex_dir.join("sessions"); + + if sessions_root.exists() { + let stats = load_session_history_stats(&sessions_root)?; + if stats.sessions > 0 + || stats.messages > 0 + || stats.total_tokens > 0 + || !stats.heatmap.is_empty() + { + return Ok(stats); + } + } + + let logs_path = codex_dir.join("logs_2.sqlite"); + if logs_path.exists() { + return load_sqlite_stats(&logs_path); + } - if !logs_path.exists() { + Ok(ClaudeStats::empty()) +} + +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(ClaudeStats::empty()); } - let conn = Connection::open(&logs_path).map_err(|e| e.to_string())?; + let mut message_count = 0u64; + let mut hour_counts: HashMap = HashMap::new(); + let mut daily_activity: HashMap = HashMap::new(); + let mut daily_model: HashMap> = + HashMap::new(); + let mut model_totals_acc: HashMap = HashMap::new(); + let mut active_dates: BTreeSet = BTreeSet::new(); + let mut session_count = 0u64; + + for path in session_files { + let Some(session_date) = session_date_from_path(&path) else { + continue; + }; + + session_count += 1; + active_dates.insert(session_date); + *daily_activity.entry(session_date).or_insert(0) += 1; + + ingest_session_file( + &path, + session_date, + &mut message_count, + &mut hour_counts, + &mut daily_activity, + &mut daily_model, + &mut model_totals_acc, + &mut active_dates, + ); + } + + Ok(build_stats( + session_count, + message_count, + hour_counts, + daily_activity, + daily_model, + model_totals_acc, + active_dates, + )) +} + +fn ingest_session_file( + path: &Path, + session_date: NaiveDate, + message_count: &mut u64, + hour_counts: &mut HashMap, + daily_activity: &mut HashMap, + daily_model: &mut HashMap>, + model_totals_acc: &mut HashMap, + active_dates: &mut BTreeSet, +) { + 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") => { + *message_count += 1; + *daily_activity.entry(session_date).or_insert(0) += 1; + active_dates.insert(session_date); + + if let Some(hour) = extract_local_hour(&value) { + *hour_counts.entry(hour).or_insert(0) += 1; + } + } + 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; + + if delta_input == 0 && delta_output == 0 { + continue; + } + + let model = current_model + .clone() + .unwrap_or_else(|| "unknown".to_string()); + + let entry = daily_model + .entry(session_date) + .or_default() + .entry(model.clone()) + .or_default(); + entry.input_tokens += delta_input; + entry.output_tokens += delta_output; + + let total_entry = model_totals_acc.entry(model).or_default(); + total_entry.input_tokens += delta_input; + total_entry.output_tokens += delta_output; + + active_dates.insert(session_date); + } + _ => {} + } + } +} + +fn load_sqlite_stats(logs_path: &Path) -> Result { + let conn = Connection::open(logs_path).map_err(|e| e.to_string())?; let mut session_ids: HashSet = HashSet::new(); let mut submission_ids: HashSet = HashSet::new(); let mut completion_ids: HashSet = HashSet::new(); let mut message_count = 0u64; let mut hour_counts: HashMap = HashMap::new(); + let mut daily_activity: HashMap = HashMap::new(); let mut daily_model: HashMap> = HashMap::new(); let mut model_totals_acc: HashMap = HashMap::new(); @@ -96,12 +261,10 @@ pub async fn get_claude_stats() -> Result { } let event_time = extract_value(&body, "event.timestamp") - .and_then(|value| { - DateTime::parse_from_rfc3339(&value) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - }) - .or_else(|| DateTime::::from_timestamp(ts, 0)); + .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; @@ -109,6 +272,7 @@ pub async fn get_claude_stats() -> Result { let date = event_time.date_naive(); active_dates.insert(date); + *daily_activity.entry(date).or_insert(0) += 1; let entry = daily_model .entry(date) @@ -153,22 +317,61 @@ pub async fn get_claude_stats() -> Result { if !submission_ids.insert(submission_id) { continue; } + message_count += 1; if let Some(event_time) = DateTime::::from_timestamp(ts, 0) { - *hour_counts.entry(event_time.hour() as u8).or_insert(0) += 1; + let local_time = event_time.with_timezone(&Local); + *hour_counts.entry(local_time.hour() as u8).or_insert(0) += 1; + let date = local_time.date_naive(); + active_dates.insert(date); + *daily_activity.entry(date).or_insert(0) += 1; } } } - let mut heatmap: Vec = daily_model + Ok(build_stats( + session_ids.len() as u64, + message_count, + hour_counts, + daily_activity, + daily_model, + model_totals_acc, + active_dates, + )) +} + +fn build_stats( + sessions: u64, + messages: u64, + hour_counts: HashMap, + daily_activity: HashMap, + daily_model: HashMap>, + model_totals_acc: HashMap, + active_dates: BTreeSet, +) -> ClaudeStats { + let mut heatmap: Vec = active_dates .iter() - .map(|(date, models)| HeatmapDay { - date: date.to_string(), - count: models - .values() - .map(DailyModelAccumulator::total_tokens) - .sum(), + .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)); @@ -231,7 +434,7 @@ pub async fn get_claude_stats() -> Result { .then_with(|| a.model.cmp(&b.model)) }); - let today = Utc::now().date_naive(); + 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 @@ -243,9 +446,9 @@ pub async fn get_claude_stats() -> Result { let total_output_tokens: u64 = model_totals.iter().map(|item| item.output_tokens).sum(); let total_tokens = total_input_tokens + total_output_tokens; - Ok(ClaudeStats { - sessions: session_ids.len() as u64, - messages: message_count, + ClaudeStats { + sessions, + messages, total_input_tokens, total_output_tokens, total_tokens, @@ -258,7 +461,65 @@ pub async fn get_claude_stats() -> Result { 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 { From 2fdcb8bb4e152aefa41364cc3996a4e96639cf5c Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Sun, 19 Apr 2026 13:11:49 -0400 Subject: [PATCH 5/6] refactor: rename stats APIs for codex --- src-tauri/src/commands/stats.rs | 16 ++++++++-------- src-tauri/src/lib.rs | 6 +++--- src-tauri/src/types.rs | 4 ++-- src/components/StatsModal.tsx | 18 ++++++------------ src/types/index.ts | 2 +- 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs index 2024c062..d1c66c6c 100644 --- a/src-tauri/src/commands/stats.rs +++ b/src-tauri/src/commands/stats.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Duration, Local, NaiveDate, Timelike, Utc}; use rusqlite::Connection; use serde_json::Value; -use crate::types::{ClaudeStats, DailyModelData, HeatmapDay, ModelTokenBreakdown, ModelTotals}; +use crate::types::{CodexStats, DailyModelData, HeatmapDay, ModelTokenBreakdown, ModelTotals}; #[derive(Default)] struct DailyModelAccumulator { @@ -34,7 +34,7 @@ impl ModelAccumulator { } #[tauri::command] -pub async fn get_claude_stats() -> Result { +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"); @@ -55,16 +55,16 @@ pub async fn get_claude_stats() -> Result { return load_sqlite_stats(&logs_path); } - Ok(ClaudeStats::empty()) + Ok(CodexStats::empty()) } -fn load_session_history_stats(root: &Path) -> Result { +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(ClaudeStats::empty()); + return Ok(CodexStats::empty()); } let mut message_count = 0u64; @@ -201,7 +201,7 @@ fn ingest_session_file( } } -fn load_sqlite_stats(logs_path: &Path) -> Result { +fn load_sqlite_stats(logs_path: &Path) -> Result { let conn = Connection::open(logs_path).map_err(|e| e.to_string())?; let mut session_ids: HashSet = HashSet::new(); @@ -349,7 +349,7 @@ fn build_stats( daily_model: HashMap>, model_totals_acc: HashMap, active_dates: BTreeSet, -) -> ClaudeStats { +) -> CodexStats { let mut heatmap: Vec = active_dates .iter() .map(|date| { @@ -446,7 +446,7 @@ fn build_stats( let total_output_tokens: u64 = model_totals.iter().map(|item| item.output_tokens).sum(); let total_tokens = total_input_tokens + total_output_tokens; - ClaudeStats { + CodexStats { sessions, messages, total_input_tokens, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f03952b..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_claude_stats, 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,8 +52,8 @@ pub fn run() { warmup_all_accounts, // Process detection check_codex_processes, - // Claude Code stats - get_claude_stats, + // 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 8ac2709c..97205738 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -318,7 +318,7 @@ pub struct ModelTokenBreakdown { /// Aggregated stats from Codex local telemetry. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClaudeStats { +pub struct CodexStats { pub sessions: u64, pub messages: u64, pub total_input_tokens: u64, @@ -340,7 +340,7 @@ pub struct ClaudeStats { pub fun_fact: Option, } -impl ClaudeStats { +impl CodexStats { pub fn empty() -> Self { Self { sessions: 0, diff --git a/src/components/StatsModal.tsx b/src/components/StatsModal.tsx index e92037ba..1c592042 100644 --- a/src/components/StatsModal.tsx +++ b/src/components/StatsModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef } from "react"; import type { - ClaudeStats, + CodexStats, DailyModelData, ModelTokenBreakdown, ModelTotals, @@ -41,12 +41,6 @@ function fmtHour(h: number | null): string { } function fmtModelName(model: string): string { - const claudeMatch = model.match(/^claude-([a-z]+)-(\d+)-(\d+)/); - if (claudeMatch) { - const [, family, maj, min] = claudeMatch; - return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${maj}.${min}`; - } - if (model.startsWith("gpt-")) { return model.toUpperCase(); } @@ -100,7 +94,7 @@ type TabId = "overview" | "models"; // ── Heatmap calendar ───────────────────────────────────────────────────────── -function HeatmapCalendar({ heatmap }: { heatmap: ClaudeStats["heatmap"] }) { +function HeatmapCalendar({ heatmap }: { heatmap: CodexStats["heatmap"] }) { const today = startOfLocalDay(new Date()); const scrollRef = useRef(null); // Start from the Sunday 52 full weeks back @@ -388,7 +382,7 @@ interface StatsModalProps { export function StatsModal({ isOpen, onClose }: StatsModalProps) { const [tab, setTab] = useState("overview"); const [range, setRange] = useState("all"); - const [stats, setStats] = useState(null); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -396,7 +390,7 @@ export function StatsModal({ isOpen, onClose }: StatsModalProps) { if (!isOpen) return; setLoading(true); setError(null); - invokeBackend("get_claude_stats") + invokeBackend("get_codex_stats") .then(setStats) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); @@ -513,7 +507,7 @@ export function StatsModal({ isOpen, onClose }: StatsModalProps) { // ── Overview tab ────────────────────────────────────────────────────────────── -function OverviewTab({ stats, range }: { stats: ClaudeStats; range: TimeRange }) { +function OverviewTab({ stats, range }: { stats: CodexStats; range: TimeRange }) { // For filtered ranges, recalculate the scalar stats from daily data const cutoff = cutoffDate(range); const heatmapFiltered = cutoff @@ -624,7 +618,7 @@ function ModelsTab({ filteredTotals, range, }: { - stats: ClaudeStats; + stats: CodexStats; filteredTotals: ModelTotals[]; range: TimeRange; }) { diff --git a/src/types/index.ts b/src/types/index.ts index f85d04fe..d545c080 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,7 +84,7 @@ export interface ModelTotals { percentage: number; } -export interface ClaudeStats { +export interface CodexStats { sessions: number; messages: number; total_input_tokens: number; From 5de875beb3fd3a23aacaad464f76366cfb80dbe8 Mon Sep 17 00:00:00 2001 From: MrFruitDude Date: Thu, 23 Apr 2026 09:00:36 -0400 Subject: [PATCH 6/6] fix: merge stats sources for filtered overview --- src-tauri/src/commands/stats.rs | 367 +++++++++++++++++++++----------- src-tauri/src/types.rs | 14 ++ src/components/StatsModal.tsx | 113 +++++++--- src/types/index.ts | 8 + 4 files changed, 338 insertions(+), 164 deletions(-) diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs index d1c66c6c..96d72761 100644 --- a/src-tauri/src/commands/stats.rs +++ b/src-tauri/src/commands/stats.rs @@ -7,7 +7,9 @@ use chrono::{DateTime, Duration, Local, NaiveDate, Timelike, Utc}; use rusqlite::Connection; use serde_json::Value; -use crate::types::{CodexStats, DailyModelData, HeatmapDay, ModelTokenBreakdown, ModelTotals}; +use crate::types::{ + CodexStats, DailyModelData, DailyOverviewData, HeatmapDay, ModelTokenBreakdown, ModelTotals, +}; #[derive(Default)] struct DailyModelAccumulator { @@ -33,91 +35,170 @@ impl ModelAccumulator { } } +#[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() { - let stats = load_session_history_stats(&sessions_root)?; - if stats.sessions > 0 - || stats.messages > 0 - || stats.total_tokens > 0 - || !stats.heatmap.is_empty() - { - return Ok(stats); - } + stats.merge(load_session_history_stats(&sessions_root)?); } - let logs_path = codex_dir.join("logs_2.sqlite"); if logs_path.exists() { - return load_sqlite_stats(&logs_path); + stats.merge(load_sqlite_stats(&logs_path)?); + } + + if stats.is_empty() { + return Ok(CodexStats::empty()); } - Ok(CodexStats::empty()) + Ok(build_stats(stats)) } -fn load_session_history_stats(root: &Path) -> Result { +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(CodexStats::empty()); + return Ok(StatsAccumulator::default()); } - let mut message_count = 0u64; - let mut hour_counts: HashMap = HashMap::new(); - let mut daily_activity: HashMap = HashMap::new(); - let mut daily_model: HashMap> = - HashMap::new(); - let mut model_totals_acc: HashMap = HashMap::new(); - let mut active_dates: BTreeSet = BTreeSet::new(); - let mut session_count = 0u64; + let mut stats = StatsAccumulator::default(); for path in session_files { let Some(session_date) = session_date_from_path(&path) else { continue; }; - session_count += 1; - active_dates.insert(session_date); - *daily_activity.entry(session_date).or_insert(0) += 1; - - ingest_session_file( - &path, - session_date, - &mut message_count, - &mut hour_counts, - &mut daily_activity, - &mut daily_model, - &mut model_totals_acc, - &mut active_dates, - ); + stats.record_session(session_date, true); + ingest_session_file(&path, session_date, &mut stats); } - Ok(build_stats( - session_count, - message_count, - hour_counts, - daily_activity, - daily_model, - model_totals_acc, - active_dates, - )) + Ok(stats) } -fn ingest_session_file( - path: &Path, - session_date: NaiveDate, - message_count: &mut u64, - hour_counts: &mut HashMap, - daily_activity: &mut HashMap, - daily_model: &mut HashMap>, - model_totals_acc: &mut HashMap, - active_dates: &mut BTreeSet, -) { +fn ingest_session_file(path: &Path, session_date: NaiveDate, stats: &mut StatsAccumulator) { let file = match File::open(path) { Ok(file) => file, Err(_) => return, @@ -156,13 +237,7 @@ fn ingest_session_file( match value.pointer("/payload/type").and_then(Value::as_str) { Some("user_message") => { - *message_count += 1; - *daily_activity.entry(session_date).or_insert(0) += 1; - active_dates.insert(session_date); - - if let Some(hour) = extract_local_hour(&value) { - *hour_counts.entry(hour).or_insert(0) += 1; - } + stats.record_message(session_date, extract_local_hour(&value)); } Some("token_count") => { let Some((input_tokens, output_tokens)) = extract_total_token_usage(&value) else { @@ -174,46 +249,23 @@ fn ingest_session_file( last_input_tokens = input_tokens; last_output_tokens = output_tokens; - if delta_input == 0 && delta_output == 0 { - continue; - } - let model = current_model .clone() .unwrap_or_else(|| "unknown".to_string()); - - let entry = daily_model - .entry(session_date) - .or_default() - .entry(model.clone()) - .or_default(); - entry.input_tokens += delta_input; - entry.output_tokens += delta_output; - - let total_entry = model_totals_acc.entry(model).or_default(); - total_entry.input_tokens += delta_input; - total_entry.output_tokens += delta_output; - - active_dates.insert(session_date); + stats.record_tokens(session_date, model, delta_input, delta_output); } _ => {} } } } -fn load_sqlite_stats(logs_path: &Path) -> Result { +fn load_sqlite_stats(logs_path: &Path) -> Result { let conn = Connection::open(logs_path).map_err(|e| e.to_string())?; - let mut session_ids: HashSet = HashSet::new(); + 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 message_count = 0u64; - let mut hour_counts: HashMap = HashMap::new(); - let mut daily_activity: HashMap = HashMap::new(); - let mut daily_model: HashMap> = - HashMap::new(); - let mut model_totals_acc: HashMap = HashMap::new(); - let mut active_dates: BTreeSet = BTreeSet::new(); { let mut stmt = conn @@ -256,10 +308,6 @@ fn load_sqlite_stats(logs_path: &Path) -> Result { continue; } - if let Some(sid) = conversation_id { - session_ids.insert(sid); - } - let event_time = extract_value(&body, "event.timestamp") .and_then(parse_rfc3339_local) .or_else(|| { @@ -271,23 +319,27 @@ fn load_sqlite_stats(logs_path: &Path) -> Result { }; let date = event_time.date_naive(); - active_dates.insert(date); - *daily_activity.entry(date).or_insert(0) += 1; - - let entry = daily_model - .entry(date) - .or_default() - .entry(model.clone()) - .or_default(); - entry.input_tokens += input_tokens; - entry.output_tokens += output_tokens; - - let total_entry = model_totals_acc.entry(model).or_default(); - total_entry.input_tokens += input_tokens; - total_entry.output_tokens += output_tokens; + 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( @@ -318,38 +370,30 @@ fn load_sqlite_stats(logs_path: &Path) -> Result { continue; } - message_count += 1; - if let Some(event_time) = DateTime::::from_timestamp(ts, 0) { let local_time = event_time.with_timezone(&Local); - *hour_counts.entry(local_time.hour() as u8).or_insert(0) += 1; - let date = local_time.date_naive(); - active_dates.insert(date); - *daily_activity.entry(date).or_insert(0) += 1; + stats.record_message(local_time.date_naive(), Some(local_time.hour() as u8)); } } } - Ok(build_stats( - session_ids.len() as u64, - message_count, + 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_acc, + model_totals, active_dates, - )) -} + } = stats; -fn build_stats( - sessions: u64, - messages: u64, - hour_counts: HashMap, - daily_activity: HashMap, - daily_model: HashMap>, - model_totals_acc: HashMap, - active_dates: BTreeSet, -) -> CodexStats { let mut heatmap: Vec = active_dates .iter() .map(|date| { @@ -376,6 +420,21 @@ fn build_stats( .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)| { @@ -407,11 +466,11 @@ fn build_stats( .collect(); daily_model_data.sort_by(|a, b| a.date.cmp(&b.date)); - let grand_total: u64 = model_totals_acc + let grand_total: u64 = model_totals .values() .map(ModelAccumulator::total_tokens) .sum(); - let mut model_totals: Vec = model_totals_acc + let mut model_totals: Vec = model_totals .into_iter() .map(|(model, acc)| { let total_tokens = acc.total_tokens(); @@ -458,6 +517,7 @@ fn build_stats( peak_hour, favorite_model, heatmap, + daily_overview_data, daily_model_data, model_totals, fun_fact: make_fun_fact(total_tokens), @@ -630,3 +690,50 @@ fn make_fun_fact(total: u64) -> Option { } } } + +#[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/types.rs b/src-tauri/src/types.rs index 97205738..1a78acf6 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -333,6 +333,8 @@ pub struct CodexStats { 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 @@ -354,6 +356,7 @@ impl CodexStats { 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, @@ -361,6 +364,17 @@ impl CodexStats { } } +#[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" diff --git a/src/components/StatsModal.tsx b/src/components/StatsModal.tsx index 1c592042..17f97bfb 100644 --- a/src/components/StatsModal.tsx +++ b/src/components/StatsModal.tsx @@ -89,6 +89,70 @@ function cutoffDate(range: TimeRange): string | null { return isoDate(d); } +function longestStreakForDates(sortedDates: string[]): number { + let longestStreak = 0; + let streak = 0; + let previous = ""; + + for (const date of sortedDates) { + if (previous) { + const diff = (new Date(date).getTime() - new Date(previous).getTime()) / 86400000; + streak = diff === 1 ? streak + 1 : 1; + } else { + streak = 1; + } + + if (streak > longestStreak) { + longestStreak = streak; + } + + previous = date; + } + + return longestStreak; +} + +function currentStreakForDates(activeDaysSet: Set): number { + const today = isoDate(new Date()); + const yesterday = isoDate(new Date(Date.now() - 86400000)); + let streak = 0; + let checkDay = activeDaysSet.has(today) + ? today + : activeDaysSet.has(yesterday) + ? yesterday + : null; + + while (checkDay && activeDaysSet.has(checkDay)) { + streak++; + checkDay = isoDate(new Date(new Date(checkDay).getTime() - 86400000)); + } + + return streak; +} + +function peakHourForOverviewDays( + overviewDays: CodexStats["daily_overview_data"] +): number | null { + const hourlyCounts = Array.from({ length: 24 }, () => 0); + + for (const day of overviewDays) { + day.hourly_messages.forEach((count, hour) => { + hourlyCounts[hour] += count ?? 0; + }); + } + + let bestHour: number | null = null; + let bestCount = 0; + hourlyCounts.forEach((count, hour) => { + if (count > bestCount) { + bestCount = count; + bestHour = hour; + } + }); + + return bestHour; +} + type TimeRange = "all" | "30d" | "7d"; type TabId = "overview" | "models"; @@ -508,7 +572,6 @@ export function StatsModal({ isOpen, onClose }: StatsModalProps) { // ── Overview tab ────────────────────────────────────────────────────────────── function OverviewTab({ stats, range }: { stats: CodexStats; range: TimeRange }) { - // For filtered ranges, recalculate the scalar stats from daily data const cutoff = cutoffDate(range); const heatmapFiltered = cutoff ? stats.heatmap.filter((d) => d.date >= cutoff) @@ -527,51 +590,33 @@ function OverviewTab({ stats, range }: { stats: CodexStats; range: TimeRange }) 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 days = stats.daily_model_data.filter((d) => d.date >= cutoff); - const activeDaysSet = new Set(days.map((d) => d.date)); - const total = days.reduce( + 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 ); - // Streaks within filtered range - const sortedDates = Array.from(activeDaysSet).sort(); - let curStreak = 0, longestStreak = 0, streak = 0; - let prev = ""; - for (const d of sortedDates) { - if (prev) { - const diff = (new Date(d).getTime() - new Date(prev).getTime()) / 86400000; - streak = diff === 1 ? streak + 1 : 1; - } else { - streak = 1; - } - if (streak > longestStreak) longestStreak = streak; - prev = d; - } - const today = isoDate(new Date()); - const yesterday = isoDate(new Date(Date.now() - 86400000)); - curStreak = 0; - let checkDay = activeDaysSet.has(today) ? today : activeDaysSet.has(yesterday) ? yesterday : null; - while (checkDay && activeDaysSet.has(checkDay)) { - curStreak++; - const prev2 = isoDate(new Date(new Date(checkDay).getTime() - 86400000)); - checkDay = prev2; - } - const favoriteModel = Object.entries(modelBreakdowns) .sort(([, a], [, b]) => b.total_tokens - a.total_tokens)[0]?.[0] ?? null; return { - sessions: stats.sessions, - messages: stats.messages, + 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: curStreak, - longest_streak: longestStreak, - peak_hour: stats.peak_hour, + current_streak: currentStreakForDates(activeDaysSet), + longest_streak: longestStreakForDates(Array.from(activeDaysSet).sort()), + peak_hour: peakHourForOverviewDays(overviewDays), favorite_model: favoriteModel, }; - }, [stats, cutoff]); + }, [stats, cutoff, heatmapFiltered]); const cards = [ { label: "Sessions", value: fmtNum(filteredTotals.sessions) }, diff --git a/src/types/index.ts b/src/types/index.ts index d545c080..b0400980 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,6 +70,13 @@ export interface HeatmapDay { 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 @@ -96,6 +103,7 @@ export interface CodexStats { 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;