initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
113
AGENTS.md
Normal file
113
AGENTS.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# claude-stats - Agent Interface Guide
|
||||
|
||||
CLI for Claude Code usage statistics. Designed for both human and AI agent use.
|
||||
|
||||
## Quick Start (Agents)
|
||||
|
||||
```bash
|
||||
# JSON output (auto-enabled when piped)
|
||||
claude-stats --json # Summary with tokens, costs, efficiency
|
||||
claude-stats daily --json # Daily breakdown
|
||||
claude-stats efficiency --json # Cache hit rate, cost analysis
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose | Example |
|
||||
|---------|---------|---------|
|
||||
| (none) | Summary stats | `claude-stats --json` |
|
||||
| `daily` | Daily breakdown | `claude-stats daily -n 7 --json` |
|
||||
| `weekly` | Weekly breakdown | `claude-stats weekly -n 4 --json` |
|
||||
| `sessions` | Recent sessions | `claude-stats sessions -n 10 --json` |
|
||||
| `projects` | By project | `claude-stats projects --json` |
|
||||
| `hourly` | By hour | `claude-stats hourly --json` |
|
||||
| `models` | Model usage | `claude-stats models --json` |
|
||||
| `efficiency` | Cache/cost metrics | `claude-stats efficiency --json` |
|
||||
| `all` | Everything | `claude-stats all --json` |
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `--json` | JSON output (auto when piped) |
|
||||
| `-n N` | Limit/days count |
|
||||
| `-q` | Quiet (no progress) |
|
||||
| `-d PATH` | Custom data dir |
|
||||
|
||||
## JSON Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"sessions_parsed": 2478,
|
||||
"files_scanned": 2478,
|
||||
"period_days": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": 2,
|
||||
"message": "Unknown command 'daytime'",
|
||||
"suggestions": [
|
||||
"claude-stats daily -n 7",
|
||||
"claude-stats --help"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | No data found |
|
||||
| 2 | Invalid arguments |
|
||||
| 3 | Data directory not found |
|
||||
| 4 | Parse error |
|
||||
|
||||
## Error Tolerance
|
||||
|
||||
The CLI is forgiving of minor syntax issues:
|
||||
|
||||
| What You Type | Interpreted As |
|
||||
|---------------|----------------|
|
||||
| `daly`, `dayly` | `daily` |
|
||||
| `session` | `sessions` |
|
||||
| `mod`, `model` | `models` |
|
||||
| `eff`, `efficency` | `efficiency` |
|
||||
| `stats`, `summary` | `all` |
|
||||
| `-json`, `-J` | `--json` |
|
||||
| `-l`, `--limit` | `-n` |
|
||||
| `-n7` | `-n 7` |
|
||||
| `--days=7` | `--days 7` |
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Get token usage for analysis
|
||||
```bash
|
||||
claude-stats --json | jq '.data.tokens.total_billed'
|
||||
```
|
||||
|
||||
### Check cache efficiency
|
||||
```bash
|
||||
claude-stats efficiency --json | jq '.data.cache_hit_rate'
|
||||
```
|
||||
|
||||
### Daily usage trend
|
||||
```bash
|
||||
claude-stats daily -n 7 --json | jq '.data[] | {date, tokens}'
|
||||
```
|
||||
|
||||
### Find highest usage project
|
||||
```bash
|
||||
claude-stats projects --json | jq '.data[0]'
|
||||
```
|
||||
1721
Cargo.lock
generated
Normal file
1721
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
Cargo.toml
Normal file
49
Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "claude-stats"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "Detailed usage statistics for Claude Code - TUI & CLI"
|
||||
|
||||
[dependencies]
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# CLI parsing
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
|
||||
# File system
|
||||
walkdir = "2.4"
|
||||
dirs = "5.0"
|
||||
|
||||
# Parallel processing
|
||||
rayon = "1.8"
|
||||
|
||||
# Progress indication (CLI mode)
|
||||
indicatif = { version = "0.17", features = ["rayon"] }
|
||||
|
||||
# CLI output
|
||||
comfy-table = "7.1"
|
||||
colored = "2.1"
|
||||
num-format = "0.4"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
|
||||
# TUI framework
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
|
||||
# Clipboard
|
||||
arboard = "3.4"
|
||||
base64 = "0.22"
|
||||
|
||||
# Unicode width for proper text rendering
|
||||
unicode-width = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
464
src/data.rs
Normal file
464
src/data.rs
Normal file
@@ -0,0 +1,464 @@
|
||||
// Data structures and parsing logic for Claude Code statistics
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Datelike, Duration, IsoWeek, Local, NaiveDate, Timelike, Utc};
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DATA STRUCTURES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SessionEntry {
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
pub timestamp: Option<String>,
|
||||
#[serde(rename = "sessionId")]
|
||||
pub session_id: Option<String>,
|
||||
pub message: Option<Message>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SessionStats {
|
||||
pub session_id: String,
|
||||
pub project: String,
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub user_messages: u64,
|
||||
pub assistant_messages: u64,
|
||||
pub models: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
impl SessionStats {
|
||||
pub fn total_tokens(&self) -> u64 {
|
||||
self.input_tokens + self.output_tokens + self.cache_creation_tokens
|
||||
}
|
||||
|
||||
pub fn duration_minutes(&self) -> Option<i64> {
|
||||
match (self.start_time, self.end_time) {
|
||||
(Some(start), Some(end)) => Some((end - start).num_minutes()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DailyStats {
|
||||
pub date: NaiveDate,
|
||||
pub sessions: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub user_messages: u64,
|
||||
pub assistant_messages: u64,
|
||||
pub total_minutes: i64,
|
||||
}
|
||||
|
||||
impl DailyStats {
|
||||
pub fn total_tokens(&self) -> u64 {
|
||||
self.input_tokens + self.output_tokens + self.cache_creation_tokens
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WeeklyStats {
|
||||
pub sessions: u64,
|
||||
pub total_tokens: u64,
|
||||
pub user_messages: u64,
|
||||
pub total_minutes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ProjectStats {
|
||||
pub name: String,
|
||||
pub sessions: u64,
|
||||
pub total_tokens: u64,
|
||||
pub total_messages: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ModelStats {
|
||||
pub name: String,
|
||||
pub requests: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SummaryStats {
|
||||
pub total_sessions: u64,
|
||||
pub total_prompts: u64,
|
||||
pub total_responses: u64,
|
||||
pub total_minutes: i64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_tokens: u64,
|
||||
pub cache_read_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub unique_days: usize,
|
||||
pub sessions_per_day: f64,
|
||||
pub prompts_per_day: f64,
|
||||
pub tokens_per_day: u64,
|
||||
pub minutes_per_day: i64,
|
||||
pub tokens_per_prompt: u64,
|
||||
pub output_per_prompt: u64,
|
||||
pub cache_hit_rate: f64,
|
||||
pub responses_per_prompt: f64,
|
||||
pub input_cost: f64,
|
||||
pub output_cost: f64,
|
||||
pub cache_write_cost: f64,
|
||||
pub cache_read_cost: f64,
|
||||
pub total_cost: f64,
|
||||
pub cache_savings: f64,
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DATA LOADING
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn get_claude_dir(custom_path: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = custom_path {
|
||||
return Ok(path);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".claude"))
|
||||
.context("Could not find home directory")
|
||||
}
|
||||
|
||||
pub fn find_session_files(claude_dir: &PathBuf) -> Vec<PathBuf> {
|
||||
let projects_dir = claude_dir.join("projects");
|
||||
WalkDir::new(&projects_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "jsonl")
|
||||
})
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_session_file(path: &PathBuf) -> Result<SessionStats> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut stats = SessionStats::default();
|
||||
|
||||
if let Some(stem) = path.file_stem() {
|
||||
stats.session_id = stem.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Some(name) = parent.file_name() {
|
||||
let project_name = name.to_string_lossy().to_string();
|
||||
stats.project = project_name.replace('-', "/");
|
||||
if stats.project.starts_with('/') {
|
||||
stats.project = stats.project[1..].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut timestamps: Vec<DateTime<Utc>> = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let entry: SessionEntry = match serde_json::from_str(&line) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ts_str) = &entry.timestamp {
|
||||
if let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) {
|
||||
timestamps.push(ts.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
|
||||
match entry.entry_type.as_deref() {
|
||||
Some("user") => {
|
||||
if entry.message.as_ref().is_some_and(|m| m.role.as_deref() == Some("user")) {
|
||||
stats.user_messages += 1;
|
||||
}
|
||||
}
|
||||
Some("assistant") => {
|
||||
stats.assistant_messages += 1;
|
||||
if let Some(msg) = &entry.message {
|
||||
if let Some(model) = &msg.model {
|
||||
*stats.models.entry(model.clone()).or_insert(0) += 1;
|
||||
}
|
||||
if let Some(usage) = &msg.usage {
|
||||
stats.input_tokens += usage.input_tokens.unwrap_or(0);
|
||||
stats.output_tokens += usage.output_tokens.unwrap_or(0);
|
||||
stats.cache_creation_tokens +=
|
||||
usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
stats.cache_read_tokens += usage.cache_read_input_tokens.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !timestamps.is_empty() {
|
||||
stats.start_time = timestamps.iter().min().copied();
|
||||
stats.end_time = timestamps.iter().max().copied();
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub fn load_all_sessions(claude_dir: &PathBuf) -> Result<(Vec<SessionStats>, usize)> {
|
||||
let session_files = find_session_files(claude_dir);
|
||||
let file_count = session_files.len();
|
||||
|
||||
let sessions: Vec<SessionStats> = session_files
|
||||
.par_iter()
|
||||
.filter_map(|path| parse_session_file(path).ok())
|
||||
.collect();
|
||||
|
||||
Ok((sessions, file_count))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// AGGREGATION FUNCTIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn compute_summary(sessions: &[SessionStats], days: u32) -> SummaryStats {
|
||||
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
|
||||
let recent: Vec<_> = sessions
|
||||
.iter()
|
||||
.filter(|s| s.start_time.is_some_and(|t| t >= cutoff))
|
||||
.collect();
|
||||
|
||||
let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum();
|
||||
let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum();
|
||||
let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum();
|
||||
let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum();
|
||||
let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum();
|
||||
let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum();
|
||||
let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum();
|
||||
let total_tokens = total_input + total_output + total_cache_creation;
|
||||
|
||||
let unique_days = recent
|
||||
.iter()
|
||||
.filter_map(|s| s.start_time)
|
||||
.map(|t| t.with_timezone(&Local).date_naive())
|
||||
.collect::<HashSet<_>>()
|
||||
.len()
|
||||
.max(1);
|
||||
|
||||
let cache_hit_rate = if total_cache_creation + total_cache_read > 0 {
|
||||
(total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let input_cost = total_input as f64 * 0.000015;
|
||||
let output_cost = total_output as f64 * 0.000075;
|
||||
let cache_write_cost = total_cache_creation as f64 * 0.00001875;
|
||||
let cache_read_cost = total_cache_read as f64 * 0.0000015;
|
||||
let cache_savings = total_cache_read as f64 * (0.000015 - 0.0000015);
|
||||
|
||||
SummaryStats {
|
||||
total_sessions: recent.len() as u64,
|
||||
total_prompts: total_user_msgs,
|
||||
total_responses: total_assistant_msgs,
|
||||
total_minutes,
|
||||
input_tokens: total_input,
|
||||
output_tokens: total_output,
|
||||
cache_creation_tokens: total_cache_creation,
|
||||
cache_read_tokens: total_cache_read,
|
||||
total_tokens,
|
||||
unique_days,
|
||||
sessions_per_day: recent.len() as f64 / unique_days as f64,
|
||||
prompts_per_day: total_user_msgs as f64 / unique_days as f64,
|
||||
tokens_per_day: total_tokens / unique_days as u64,
|
||||
minutes_per_day: total_minutes / unique_days as i64,
|
||||
tokens_per_prompt: if total_user_msgs > 0 { total_tokens / total_user_msgs } else { 0 },
|
||||
output_per_prompt: if total_user_msgs > 0 { total_output / total_user_msgs } else { 0 },
|
||||
cache_hit_rate,
|
||||
responses_per_prompt: if total_user_msgs > 0 { total_assistant_msgs as f64 / total_user_msgs as f64 } else { 0.0 },
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_write_cost,
|
||||
cache_read_cost,
|
||||
total_cost: input_cost + output_cost + cache_write_cost + cache_read_cost,
|
||||
cache_savings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aggregate_daily(sessions: &[SessionStats], days: u32) -> BTreeMap<NaiveDate, DailyStats> {
|
||||
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
|
||||
let mut daily: BTreeMap<NaiveDate, DailyStats> = BTreeMap::new();
|
||||
|
||||
for session in sessions {
|
||||
if let Some(start) = session.start_time {
|
||||
if start < cutoff {
|
||||
continue;
|
||||
}
|
||||
let date = start.with_timezone(&Local).date_naive();
|
||||
let entry = daily.entry(date).or_insert_with(|| DailyStats {
|
||||
date,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
entry.sessions += 1;
|
||||
entry.input_tokens += session.input_tokens;
|
||||
entry.output_tokens += session.output_tokens;
|
||||
entry.cache_creation_tokens += session.cache_creation_tokens;
|
||||
entry.cache_read_tokens += session.cache_read_tokens;
|
||||
entry.user_messages += session.user_messages;
|
||||
entry.assistant_messages += session.assistant_messages;
|
||||
if let Some(mins) = session.duration_minutes() {
|
||||
entry.total_minutes += mins;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
daily
|
||||
}
|
||||
|
||||
pub fn aggregate_weekly(sessions: &[SessionStats], weeks: u32) -> BTreeMap<IsoWeek, WeeklyStats> {
|
||||
let cutoff = Local::now().with_timezone(&Utc) - Duration::weeks(weeks as i64);
|
||||
let mut weekly: BTreeMap<IsoWeek, WeeklyStats> = BTreeMap::new();
|
||||
|
||||
for session in sessions {
|
||||
if let Some(start) = session.start_time {
|
||||
if start < cutoff {
|
||||
continue;
|
||||
}
|
||||
let week = start.with_timezone(&Local).date_naive().iso_week();
|
||||
let entry = weekly.entry(week).or_insert_with(|| WeeklyStats {
|
||||
sessions: 0,
|
||||
total_tokens: 0,
|
||||
user_messages: 0,
|
||||
total_minutes: 0,
|
||||
});
|
||||
|
||||
entry.sessions += 1;
|
||||
entry.total_tokens += session.total_tokens();
|
||||
entry.user_messages += session.user_messages;
|
||||
if let Some(mins) = session.duration_minutes() {
|
||||
entry.total_minutes += mins;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weekly
|
||||
}
|
||||
|
||||
pub fn aggregate_hourly(sessions: &[SessionStats], days: u32) -> [u64; 24] {
|
||||
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
|
||||
let mut hourly = [0u64; 24];
|
||||
|
||||
for session in sessions {
|
||||
if let Some(start) = session.start_time {
|
||||
if start >= cutoff {
|
||||
let local: DateTime<Local> = start.into();
|
||||
let hour = local.hour() as usize;
|
||||
hourly[hour] += session.user_messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hourly
|
||||
}
|
||||
|
||||
pub fn aggregate_projects(sessions: &[SessionStats]) -> Vec<ProjectStats> {
|
||||
let mut projects: HashMap<String, ProjectStats> = HashMap::new();
|
||||
|
||||
for session in sessions {
|
||||
let entry = projects
|
||||
.entry(session.project.clone())
|
||||
.or_insert_with(|| ProjectStats {
|
||||
name: session.project.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
entry.sessions += 1;
|
||||
entry.total_tokens += session.total_tokens();
|
||||
entry.total_messages += session.user_messages + session.assistant_messages;
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = projects.into_values().collect();
|
||||
result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||
result
|
||||
}
|
||||
|
||||
pub fn aggregate_models(sessions: &[SessionStats], days: u32) -> Vec<ModelStats> {
|
||||
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
|
||||
let mut models: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
for session in sessions {
|
||||
if session.start_time.is_some_and(|t| t >= cutoff) {
|
||||
for (model, count) in &session.models {
|
||||
*models.entry(model.clone()).or_insert(0) += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = models
|
||||
.into_iter()
|
||||
.map(|(name, requests)| ModelStats { name, requests })
|
||||
.collect();
|
||||
result.sort_by(|a, b| b.requests.cmp(&a.requests));
|
||||
result
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FORMATTING HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn format_tokens(tokens: u64) -> String {
|
||||
if tokens >= 1_000_000_000 {
|
||||
format!("{:.2}B", tokens as f64 / 1_000_000_000.0)
|
||||
} else if tokens >= 1_000_000 {
|
||||
format!("{:.2}M", tokens as f64 / 1_000_000.0)
|
||||
} else if tokens >= 1_000 {
|
||||
format!("{:.1}K", tokens as f64 / 1_000.0)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_duration(minutes: i64) -> String {
|
||||
if minutes >= 60 {
|
||||
format!("{}h {}m", minutes / 60, minutes % 60)
|
||||
} else {
|
||||
format!("{}m", minutes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_number(n: u64) -> String {
|
||||
use num_format::{Locale, ToFormattedString};
|
||||
n.to_formatted_string(&Locale::en)
|
||||
}
|
||||
1897
src/main.rs
Normal file
1897
src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
443
src/tui/app.rs
Normal file
443
src/tui/app.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
// TUI Application state and event handling
|
||||
|
||||
use crate::data::*;
|
||||
use crate::tui::report::generate_report;
|
||||
use crate::tui::views::*;
|
||||
use anyhow::Result;
|
||||
use arboard::Clipboard;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Tabs, Clear},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// APP STATE
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum View {
|
||||
Summary,
|
||||
Daily,
|
||||
Weekly,
|
||||
Projects,
|
||||
Models,
|
||||
Hourly,
|
||||
Report,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn all() -> Vec<View> {
|
||||
vec![
|
||||
View::Summary,
|
||||
View::Daily,
|
||||
View::Weekly,
|
||||
View::Projects,
|
||||
View::Models,
|
||||
View::Hourly,
|
||||
View::Report,
|
||||
]
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
match self {
|
||||
View::Summary => "Summary",
|
||||
View::Daily => "Daily",
|
||||
View::Weekly => "Weekly",
|
||||
View::Projects => "Projects",
|
||||
View::Models => "Models",
|
||||
View::Hourly => "Hourly",
|
||||
View::Report => "Report",
|
||||
}
|
||||
}
|
||||
|
||||
fn index(&self) -> usize {
|
||||
match self {
|
||||
View::Summary => 0,
|
||||
View::Daily => 1,
|
||||
View::Weekly => 2,
|
||||
View::Projects => 3,
|
||||
View::Models => 4,
|
||||
View::Hourly => 5,
|
||||
View::Report => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub sessions: Vec<SessionStats>,
|
||||
pub file_count: usize,
|
||||
pub current_view: View,
|
||||
pub days: u32,
|
||||
pub scroll_offset: usize,
|
||||
pub show_help: bool,
|
||||
pub status_message: Option<(String, std::time::Instant)>,
|
||||
pub report_content: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(sessions: Vec<SessionStats>, file_count: usize, days: u32) -> Self {
|
||||
Self {
|
||||
sessions,
|
||||
file_count,
|
||||
current_view: View::Summary,
|
||||
days,
|
||||
scroll_offset: 0,
|
||||
show_help: false,
|
||||
status_message: None,
|
||||
report_content: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_view(&mut self) {
|
||||
let views = View::all();
|
||||
let current_idx = self.current_view.index();
|
||||
self.current_view = views[(current_idx + 1) % views.len()];
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
pub fn prev_view(&mut self) {
|
||||
let views = View::all();
|
||||
let current_idx = self.current_view.index();
|
||||
self.current_view = views[(current_idx + views.len() - 1) % views.len()];
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(10);
|
||||
}
|
||||
|
||||
pub fn increase_days(&mut self) {
|
||||
self.days = (self.days + 7).min(365);
|
||||
}
|
||||
|
||||
pub fn decrease_days(&mut self) {
|
||||
self.days = self.days.saturating_sub(7).max(1);
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, msg: &str) {
|
||||
self.status_message = Some((msg.to_string(), std::time::Instant::now()));
|
||||
}
|
||||
|
||||
pub fn copy_to_clipboard(&mut self) -> Result<()> {
|
||||
use base64::Engine;
|
||||
use std::io::Write;
|
||||
|
||||
let report = generate_report(&self.sessions, self.days);
|
||||
self.report_content = report.clone();
|
||||
|
||||
// Try OSC 52 first (works over SSH with modern terminals like WezTerm)
|
||||
let osc52_result = (|| -> Result<()> {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&report);
|
||||
let mut stdout = std::io::stdout();
|
||||
// OSC 52 format: ESC ] 52 ; c ; <base64> BEL
|
||||
write!(stdout, "\x1b]52;c;{}\x07", encoded)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if osc52_result.is_ok() {
|
||||
self.set_status("✓ Report copied via OSC 52!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fall back to arboard (X11/Wayland)
|
||||
match Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
clipboard.set_text(&report)?;
|
||||
self.set_status("✓ Report copied to clipboard!");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(&format!("✗ Clipboard error: {}", e));
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_report(&mut self) {
|
||||
self.report_content = generate_report(&self.sessions, self.days);
|
||||
self.current_view = View::Report;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TUI RUNNER
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn run_tui(claude_dir: PathBuf, days: u32) -> Result<()> {
|
||||
// Load data
|
||||
let (sessions, file_count) = load_all_sessions(&claude_dir)?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
anyhow::bail!("No sessions found in {:?}", claude_dir);
|
||||
}
|
||||
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Create app
|
||||
let mut app = App::new(sessions, file_count, days);
|
||||
|
||||
// Run event loop
|
||||
let result = run_app(&mut terminal, &mut app);
|
||||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn run_app<B: ratatui::backend::Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut App,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| draw_ui(f, app))?;
|
||||
|
||||
// Clear expired status messages
|
||||
if let Some((_, time)) = &app.status_message {
|
||||
if time.elapsed() > Duration::from_secs(3) {
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
// Handle help overlay first
|
||||
if app.show_help {
|
||||
app.show_help = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
// Quit
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
// Navigation
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => app.next_view(),
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => app.prev_view(),
|
||||
|
||||
// Direct view access
|
||||
KeyCode::Char('1') => app.current_view = View::Summary,
|
||||
KeyCode::Char('2') => app.current_view = View::Daily,
|
||||
KeyCode::Char('3') => app.current_view = View::Weekly,
|
||||
KeyCode::Char('4') => app.current_view = View::Projects,
|
||||
KeyCode::Char('5') => app.current_view = View::Models,
|
||||
KeyCode::Char('6') => app.current_view = View::Hourly,
|
||||
KeyCode::Char('7') => app.current_view = View::Report,
|
||||
|
||||
// Scrolling
|
||||
KeyCode::Up | KeyCode::Char('k') => app.scroll_up(),
|
||||
KeyCode::Down | KeyCode::Char('j') => app.scroll_down(),
|
||||
KeyCode::PageUp => app.page_up(),
|
||||
KeyCode::PageDown => app.page_down(),
|
||||
KeyCode::Home => app.scroll_offset = 0,
|
||||
|
||||
// Time range
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => app.increase_days(),
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => app.decrease_days(),
|
||||
|
||||
// Actions
|
||||
KeyCode::Char('c') => {
|
||||
let _ = app.copy_to_clipboard();
|
||||
}
|
||||
KeyCode::Char('r') => app.generate_report(),
|
||||
KeyCode::Char('?') => app.show_help = true,
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// UI DRAWING
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn draw_ui(f: &mut Frame, app: &App) {
|
||||
let size = f.area();
|
||||
|
||||
// Main layout: header, content, footer
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header/tabs
|
||||
Constraint::Min(10), // Content
|
||||
Constraint::Length(3), // Footer
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// Draw header with tabs
|
||||
draw_header(f, app, chunks[0]);
|
||||
|
||||
// Draw main content
|
||||
draw_content(f, app, chunks[1]);
|
||||
|
||||
// Draw footer
|
||||
draw_footer(f, app, chunks[2]);
|
||||
|
||||
// Draw help overlay if active
|
||||
if app.show_help {
|
||||
draw_help_overlay(f, size);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
|
||||
let titles: Vec<Line> = View::all()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let title = format!(" {} {} ", i + 1, v.title());
|
||||
Line::from(title)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tabs = Tabs::new(titles)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" Claude Code Stats ")
|
||||
.title_style(Style::default().fg(Color::Cyan).bold()),
|
||||
)
|
||||
.select(app.current_view.index())
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
f.render_widget(tabs, area);
|
||||
}
|
||||
|
||||
fn draw_content(f: &mut Frame, app: &App, area: Rect) {
|
||||
match app.current_view {
|
||||
View::Summary => draw_summary_view(f, app, area),
|
||||
View::Daily => draw_daily_view(f, app, area),
|
||||
View::Weekly => draw_weekly_view(f, app, area),
|
||||
View::Projects => draw_projects_view(f, app, area),
|
||||
View::Models => draw_models_view(f, app, area),
|
||||
View::Hourly => draw_hourly_view(f, app, area),
|
||||
View::Report => draw_report_view(f, app, area),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
let status_text = if let Some((msg, _)) = &app.status_message {
|
||||
msg.clone()
|
||||
} else {
|
||||
format!(
|
||||
" {} days │ {} sessions │ Tab/←→: navigate │ c: copy │ r: report │ +/-: days │ ?: help │ q: quit",
|
||||
app.days,
|
||||
format_number(app.sessions.len() as u64)
|
||||
)
|
||||
};
|
||||
|
||||
let status_style = if app.status_message.is_some() {
|
||||
Style::default().fg(Color::Green).bold()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(status_text)
|
||||
.style(status_style)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
);
|
||||
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
|
||||
fn draw_help_overlay(f: &mut Frame, area: Rect) {
|
||||
// Center the help popup
|
||||
let popup_width = 60.min(area.width - 4);
|
||||
let popup_height = 20.min(area.height - 4);
|
||||
let popup_x = (area.width - popup_width) / 2;
|
||||
let popup_y = (area.height - popup_height) / 2;
|
||||
|
||||
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let help_text = vec![
|
||||
Line::from(Span::styled("KEYBOARD SHORTCUTS", Style::default().bold().fg(Color::Cyan))),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Navigation", Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
Line::from(" Tab/→/l Next view"),
|
||||
Line::from(" Shift+Tab/←/h Previous view"),
|
||||
Line::from(" 1-7 Jump to view"),
|
||||
Line::from(" ↑/k ↓/j Scroll up/down"),
|
||||
Line::from(" PgUp/PgDn Page up/down"),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Actions", Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
Line::from(" c Copy report to clipboard"),
|
||||
Line::from(" r Generate report view"),
|
||||
Line::from(" +/- Increase/decrease days"),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("General", Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
Line::from(" ? Show this help"),
|
||||
Line::from(" q/Esc Quit"),
|
||||
];
|
||||
|
||||
let help = Paragraph::new(help_text)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Help ")
|
||||
.title_style(Style::default().fg(Color::Cyan).bold())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
f.render_widget(help, popup_area);
|
||||
}
|
||||
7
src/tui/mod.rs
Normal file
7
src/tui/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// TUI module for claude-stats
|
||||
|
||||
mod app;
|
||||
mod views;
|
||||
mod report;
|
||||
|
||||
pub use app::run_tui;
|
||||
204
src/tui/report.rs
Normal file
204
src/tui/report.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
// Report generation for clipboard export
|
||||
|
||||
use crate::data::*;
|
||||
use chrono::Local;
|
||||
|
||||
/// Generate a well-formatted text report suitable for clipboard
|
||||
pub fn generate_report(sessions: &[SessionStats], days: u32) -> String {
|
||||
let summary = compute_summary(sessions, days);
|
||||
let daily = aggregate_daily(sessions, days.min(14));
|
||||
let models = aggregate_models(sessions, days);
|
||||
let projects = aggregate_projects(sessions);
|
||||
|
||||
let now = Local::now();
|
||||
let mut report = String::new();
|
||||
|
||||
// Header
|
||||
report.push_str(&format!(
|
||||
"# Claude Code Usage Report\n\
|
||||
Generated: {}\n\
|
||||
Period: {} days\n\n",
|
||||
now.format("%Y-%m-%d %H:%M"),
|
||||
days
|
||||
));
|
||||
|
||||
// Summary section
|
||||
report.push_str("## Summary\n\n");
|
||||
report.push_str(&format!(
|
||||
"┌─────────────────────────────────────────────────┐\n\
|
||||
│ Sessions: {:>12} │\n\
|
||||
│ Prompts: {:>12} │\n\
|
||||
│ Responses: {:>12} │\n\
|
||||
│ Active Days: {:>12} │\n\
|
||||
│ Total Time: {:>12} │\n\
|
||||
└─────────────────────────────────────────────────┘\n\n",
|
||||
format_number(summary.total_sessions),
|
||||
format_number(summary.total_prompts),
|
||||
format_number(summary.total_responses),
|
||||
summary.unique_days,
|
||||
format_duration(summary.total_minutes),
|
||||
));
|
||||
|
||||
// Token usage
|
||||
report.push_str("## Token Usage\n\n");
|
||||
report.push_str(&format!(
|
||||
"┌─────────────────────────────────────────────────┐\n\
|
||||
│ Input Tokens: {:>12} │\n\
|
||||
│ Output Tokens: {:>12} │\n\
|
||||
│ Cache Write: {:>12} │\n\
|
||||
│ Cache Read: {:>12} │\n\
|
||||
├─────────────────────────────────────────────────┤\n\
|
||||
│ TOTAL BILLED: {:>12} │\n\
|
||||
└─────────────────────────────────────────────────┘\n\n",
|
||||
format_tokens(summary.input_tokens),
|
||||
format_tokens(summary.output_tokens),
|
||||
format_tokens(summary.cache_creation_tokens),
|
||||
format_tokens(summary.cache_read_tokens),
|
||||
format_tokens(summary.total_tokens),
|
||||
));
|
||||
|
||||
// Daily averages
|
||||
report.push_str("## Daily Averages\n\n");
|
||||
report.push_str(&format!(
|
||||
" Sessions/day: {:.1}\n\
|
||||
Prompts/day: {:.1}\n\
|
||||
Tokens/day: {}\n\
|
||||
Time/day: {}\n\n",
|
||||
summary.sessions_per_day,
|
||||
summary.prompts_per_day,
|
||||
format_tokens(summary.tokens_per_day),
|
||||
format_duration(summary.minutes_per_day),
|
||||
));
|
||||
|
||||
// Efficiency metrics
|
||||
report.push_str("## Efficiency\n\n");
|
||||
report.push_str(&format!(
|
||||
" Tokens/prompt: {}\n\
|
||||
Output/prompt: {}\n\
|
||||
Responses/prompt: {:.2}\n\
|
||||
Cache hit rate: {:.1}%\n\n",
|
||||
format_tokens(summary.tokens_per_prompt),
|
||||
format_tokens(summary.output_per_prompt),
|
||||
summary.responses_per_prompt,
|
||||
summary.cache_hit_rate,
|
||||
));
|
||||
|
||||
// Cost estimate
|
||||
report.push_str("## API Cost Equivalent (Opus 4.5 Pricing)\n\n");
|
||||
report.push_str(&format!(
|
||||
"┌─────────────────────────────────────────────────┐\n\
|
||||
│ Input: ${:>10.2} │\n\
|
||||
│ Output: ${:>10.2} │\n\
|
||||
│ Cache Write: ${:>10.2} │\n\
|
||||
│ Cache Read: ${:>10.2} │\n\
|
||||
├─────────────────────────────────────────────────┤\n\
|
||||
│ TOTAL: ${:>10.2} │\n\
|
||||
│ Cache Savings: ${:>10.2} │\n\
|
||||
└─────────────────────────────────────────────────┘\n\n",
|
||||
summary.input_cost,
|
||||
summary.output_cost,
|
||||
summary.cache_write_cost,
|
||||
summary.cache_read_cost,
|
||||
summary.total_cost,
|
||||
summary.cache_savings,
|
||||
));
|
||||
|
||||
// Daily breakdown (last 14 days max)
|
||||
report.push_str("## Daily Breakdown\n\n");
|
||||
report.push_str(" Date │ Sessions │ Prompts │ Tokens │ Time\n");
|
||||
report.push_str(" ────────────┼──────────┼─────────┼──────────┼─────────\n");
|
||||
|
||||
for (date, stats) in daily.iter().rev().take(14) {
|
||||
report.push_str(&format!(
|
||||
" {} │ {:>8} │ {:>7} │ {:>8} │ {}\n",
|
||||
date.format("%Y-%m-%d"),
|
||||
stats.sessions,
|
||||
stats.user_messages,
|
||||
format_tokens(stats.total_tokens()),
|
||||
format_duration(stats.total_minutes),
|
||||
));
|
||||
}
|
||||
report.push('\n');
|
||||
|
||||
// Model usage
|
||||
if !models.is_empty() {
|
||||
let total_requests: u64 = models.iter().map(|m| m.requests).sum();
|
||||
report.push_str("## Model Usage\n\n");
|
||||
report.push_str(" Model │ Requests │ %\n");
|
||||
report.push_str(" ───────────────────────────┼──────────┼────────\n");
|
||||
|
||||
for model in models.iter().take(5) {
|
||||
let pct = if total_requests > 0 {
|
||||
(model.requests as f64 / total_requests as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let short_name = model.name
|
||||
.replace("claude-", "")
|
||||
.replace("-20251101", "")
|
||||
.replace("-20250929", "")
|
||||
.replace("-20251001", "")
|
||||
.replace("-20250514", "");
|
||||
|
||||
report.push_str(&format!(
|
||||
" {:<26} │ {:>8} │ {:>5.1}%\n",
|
||||
short_name,
|
||||
format_number(model.requests),
|
||||
pct,
|
||||
));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
|
||||
// Top projects
|
||||
if !projects.is_empty() {
|
||||
let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum();
|
||||
report.push_str("## Top Projects\n\n");
|
||||
report.push_str(" Project │ Sessions │ Tokens │ %\n");
|
||||
report.push_str(" ──────────────────────────────────────┼──────────┼──────────┼────────\n");
|
||||
|
||||
for project in projects.iter().take(10) {
|
||||
let pct = if total_tokens > 0 {
|
||||
(project.total_tokens as f64 / total_tokens as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let name = if project.name.len() > 38 {
|
||||
format!("...{}", &project.name[project.name.len() - 35..])
|
||||
} else {
|
||||
project.name.clone()
|
||||
};
|
||||
|
||||
report.push_str(&format!(
|
||||
" {:<38} │ {:>8} │ {:>8} │ {:>5.1}%\n",
|
||||
name,
|
||||
project.sessions,
|
||||
format_tokens(project.total_tokens),
|
||||
pct,
|
||||
));
|
||||
}
|
||||
report.push('\n');
|
||||
}
|
||||
|
||||
// Footer
|
||||
report.push_str("---\n");
|
||||
report.push_str("Generated by claude-stats v0.2.0\n");
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
/// Generate a compact single-line summary
|
||||
pub fn generate_compact_summary(sessions: &[SessionStats], days: u32) -> String {
|
||||
let summary = compute_summary(sessions, days);
|
||||
|
||||
format!(
|
||||
"{}d: {} sessions, {} prompts, {} tokens, ${:.2} equiv, {:.1}% cache",
|
||||
days,
|
||||
format_number(summary.total_sessions),
|
||||
format_number(summary.total_prompts),
|
||||
format_tokens(summary.total_tokens),
|
||||
summary.total_cost,
|
||||
summary.cache_hit_rate,
|
||||
)
|
||||
}
|
||||
595
src/tui/views.rs
Normal file
595
src/tui/views.rs
Normal file
@@ -0,0 +1,595 @@
|
||||
// TUI view rendering
|
||||
|
||||
use crate::data::*;
|
||||
use crate::tui::app::App;
|
||||
use chrono::Datelike;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Bar, BarChart, BarGroup, Block, Borders, Cell, Paragraph, Row, Scrollbar,
|
||||
ScrollbarOrientation, ScrollbarState, Table, Wrap,
|
||||
},
|
||||
Frame,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SUMMARY VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_summary_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let summary = compute_summary(&app.sessions, app.days);
|
||||
|
||||
// Split into two columns
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
// Left column: Overview metrics
|
||||
let left_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(8), // Activity
|
||||
Constraint::Length(9), // Tokens
|
||||
Constraint::Min(6), // Averages
|
||||
])
|
||||
.split(columns[0]);
|
||||
|
||||
// Right column: Cost and efficiency
|
||||
let right_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(10), // Cost breakdown
|
||||
Constraint::Min(7), // Efficiency
|
||||
])
|
||||
.split(columns[1]);
|
||||
|
||||
// Activity panel
|
||||
let activity_content = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Sessions: "),
|
||||
Span::styled(format_number(summary.total_sessions), Style::default().fg(Color::Green).bold()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Prompts: "),
|
||||
Span::styled(format_number(summary.total_prompts), Style::default().fg(Color::Green).bold()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Responses: "),
|
||||
Span::styled(format_number(summary.total_responses), Style::default().fg(Color::Green)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Total Time: "),
|
||||
Span::styled(format_duration(summary.total_minutes), Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Active Days: "),
|
||||
Span::styled(format!("{}", summary.unique_days), Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
];
|
||||
|
||||
let activity_title = format!(" Activity ({} days) ", app.days);
|
||||
let activity = Paragraph::new(activity_content)
|
||||
.block(create_block(&activity_title));
|
||||
f.render_widget(activity, left_rows[0]);
|
||||
|
||||
// Tokens panel
|
||||
let tokens_content = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Input: "),
|
||||
Span::styled(format_tokens(summary.input_tokens), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Output: "),
|
||||
Span::styled(format_tokens(summary.output_tokens), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Cache Write: "),
|
||||
Span::styled(format_tokens(summary.cache_creation_tokens), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Cache Read: "),
|
||||
Span::styled(format_tokens(summary.cache_read_tokens), Style::default().fg(Color::Blue)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::raw(" TOTAL: "),
|
||||
Span::styled(format_tokens(summary.total_tokens), Style::default().fg(Color::Yellow).bold()),
|
||||
]),
|
||||
];
|
||||
|
||||
let tokens = Paragraph::new(tokens_content)
|
||||
.block(create_block(" Tokens "));
|
||||
f.render_widget(tokens, left_rows[1]);
|
||||
|
||||
// Averages panel
|
||||
let averages_content = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Sessions/day: "),
|
||||
Span::styled(format!("{:.1}", summary.sessions_per_day), Style::default().fg(Color::Green)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Prompts/day: "),
|
||||
Span::styled(format!("{:.1}", summary.prompts_per_day), Style::default().fg(Color::Green)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Tokens/day: "),
|
||||
Span::styled(format_tokens(summary.tokens_per_day), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Time/day: "),
|
||||
Span::styled(format_duration(summary.minutes_per_day), Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
];
|
||||
|
||||
let averages = Paragraph::new(averages_content)
|
||||
.block(create_block(" Daily Averages "));
|
||||
f.render_widget(averages, left_rows[2]);
|
||||
|
||||
// Cost panel
|
||||
let cost_content = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Input: "),
|
||||
Span::styled(format!("${:.2}", summary.input_cost), Style::default().fg(Color::Red)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Output: "),
|
||||
Span::styled(format!("${:.2}", summary.output_cost), Style::default().fg(Color::Red)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Cache Write: "),
|
||||
Span::styled(format!("${:.2}", summary.cache_write_cost), Style::default().fg(Color::Red)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Cache Read: "),
|
||||
Span::styled(format!("${:.2}", summary.cache_read_cost), Style::default().fg(Color::Blue)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::raw(" TOTAL: "),
|
||||
Span::styled(format!("${:.2}", summary.total_cost), Style::default().fg(Color::Yellow).bold()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Savings: "),
|
||||
Span::styled(format!("${:.2}", summary.cache_savings), Style::default().fg(Color::Green).bold()),
|
||||
]),
|
||||
];
|
||||
|
||||
let cost = Paragraph::new(cost_content)
|
||||
.block(create_block(" API Cost Equivalent (Opus 4.5) "));
|
||||
f.render_widget(cost, right_rows[0]);
|
||||
|
||||
// Efficiency panel
|
||||
let cache_bar = create_percentage_bar(summary.cache_hit_rate, "Cache Hit");
|
||||
let efficiency_content = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Tokens/prompt: "),
|
||||
Span::styled(format_tokens(summary.tokens_per_prompt), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Output/prompt: "),
|
||||
Span::styled(format_tokens(summary.output_per_prompt), Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Responses/prompt: "),
|
||||
Span::styled(format!("{:.2}", summary.responses_per_prompt), Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::raw(" Cache hit rate: "),
|
||||
Span::styled(format!("{:.1}%", summary.cache_hit_rate), Style::default().fg(Color::Green).bold()),
|
||||
]),
|
||||
Line::from(cache_bar),
|
||||
];
|
||||
|
||||
let efficiency = Paragraph::new(efficiency_content)
|
||||
.block(create_block(" Efficiency "));
|
||||
f.render_widget(efficiency, right_rows[1]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DAILY VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_daily_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let daily = aggregate_daily(&app.sessions, app.days);
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Date").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Day").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()),
|
||||
])
|
||||
.height(1)
|
||||
.bottom_margin(1);
|
||||
|
||||
let rows: Vec<Row> = daily
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(app.scroll_offset)
|
||||
.map(|(date, stats)| {
|
||||
let day_name = date.weekday().to_string();
|
||||
let day_short = day_name[..3].to_string();
|
||||
let is_weekend = date.weekday().num_days_from_monday() >= 5;
|
||||
let day_style = if is_weekend {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(date.format("%Y-%m-%d").to_string()),
|
||||
Cell::from(day_short).style(day_style),
|
||||
Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_tokens(stats.total_tokens())).style(Style::default().fg(Color::Magenta)),
|
||||
Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
];
|
||||
|
||||
let daily_title = format!(" Daily Breakdown ({} days) ", app.days);
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(create_block(&daily_title))
|
||||
.row_highlight_style(Style::default().bg(Color::DarkGray));
|
||||
|
||||
f.render_widget(table, area);
|
||||
|
||||
// Scrollbar
|
||||
let total_items = daily.len();
|
||||
render_scrollbar(f, area, app.scroll_offset, total_items);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WEEKLY VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_weekly_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let weeks = (app.days / 7).max(1);
|
||||
let weekly = aggregate_weekly(&app.sessions, weeks);
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Week").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()),
|
||||
])
|
||||
.height(1)
|
||||
.bottom_margin(1);
|
||||
|
||||
let rows: Vec<Row> = weekly
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(app.scroll_offset)
|
||||
.map(|(week, stats)| {
|
||||
Row::new(vec![
|
||||
Cell::from(format!("{}-W{:02}", week.year(), week.week())),
|
||||
Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_tokens(stats.total_tokens)).style(Style::default().fg(Color::Magenta)),
|
||||
Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
];
|
||||
|
||||
let weekly_title = format!(" Weekly Breakdown ({} weeks) ", weeks);
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(create_block(&weekly_title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PROJECTS VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_projects_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let projects = aggregate_projects(&app.sessions);
|
||||
let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum();
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Project").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Messages").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("%").style(Style::default().fg(Color::Cyan).bold()),
|
||||
])
|
||||
.height(1)
|
||||
.bottom_margin(1);
|
||||
|
||||
let rows: Vec<Row> = projects
|
||||
.iter()
|
||||
.skip(app.scroll_offset)
|
||||
.take(area.height.saturating_sub(4) as usize)
|
||||
.map(|p| {
|
||||
let pct = if total_tokens > 0 {
|
||||
(p.total_tokens as f64 / total_tokens as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Truncate project name
|
||||
let name = if p.name.len() > 40 {
|
||||
format!("...{}", &p.name[p.name.len() - 37..])
|
||||
} else {
|
||||
p.name.clone()
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(name),
|
||||
Cell::from(format_number(p.sessions)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_number(p.total_messages)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format_tokens(p.total_tokens)).style(Style::default().fg(Color::Magenta)),
|
||||
Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Min(30),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(8),
|
||||
];
|
||||
|
||||
let projects_title = format!(" Projects ({} total) ", projects.len());
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(create_block(&projects_title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
|
||||
render_scrollbar(f, area, app.scroll_offset, projects.len());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MODELS VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_models_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let models = aggregate_models(&app.sessions, app.days);
|
||||
let total: u64 = models.iter().map(|m| m.requests).sum();
|
||||
|
||||
// Split view: table on left, chart on right
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
// Table
|
||||
let header = Row::new(vec![
|
||||
Cell::from("Model").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("Requests").style(Style::default().fg(Color::Cyan).bold()),
|
||||
Cell::from("%").style(Style::default().fg(Color::Cyan).bold()),
|
||||
])
|
||||
.height(1)
|
||||
.bottom_margin(1);
|
||||
|
||||
let rows: Vec<Row> = models
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(|m| {
|
||||
let pct = if total > 0 {
|
||||
(m.requests as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Shorten model name
|
||||
let short_name = m.name
|
||||
.replace("claude-", "")
|
||||
.replace("-20251101", "")
|
||||
.replace("-20250929", "")
|
||||
.replace("-20251001", "")
|
||||
.replace("-20250514", "");
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(short_name),
|
||||
Cell::from(format_number(m.requests)).style(Style::default().fg(Color::Green)),
|
||||
Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Min(20),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(8),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(create_block(" Model Usage "));
|
||||
|
||||
f.render_widget(table, chunks[0]);
|
||||
|
||||
// Bar chart
|
||||
let bar_data: Vec<(&str, u64)> = models
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|m| {
|
||||
let short = m.name
|
||||
.replace("claude-", "")
|
||||
.replace("-20251101", "")
|
||||
.replace("-20250929", "")
|
||||
.replace("-20251001", "")
|
||||
.replace("-20250514", "");
|
||||
// Leak the string to get a static reference (acceptable for TUI lifetime)
|
||||
(Box::leak(short.into_boxed_str()) as &str, m.requests)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let bars: Vec<Bar> = bar_data
|
||||
.iter()
|
||||
.map(|(label, value)| {
|
||||
Bar::default()
|
||||
.value(*value)
|
||||
.label(Line::from(*label))
|
||||
.style(Style::default().fg(Color::Cyan))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chart = BarChart::default()
|
||||
.block(create_block(" Distribution "))
|
||||
.data(BarGroup::default().bars(&bars))
|
||||
.bar_width(8)
|
||||
.bar_gap(2)
|
||||
.bar_style(Style::default().fg(Color::Cyan))
|
||||
.value_style(Style::default().fg(Color::White));
|
||||
|
||||
f.render_widget(chart, chunks[1]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// HOURLY VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_hourly_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let hourly = aggregate_hourly(&app.sessions, app.days);
|
||||
let max_activity = *hourly.iter().max().unwrap_or(&1);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (hour, &count) in hourly.iter().enumerate() {
|
||||
let bar_width = if max_activity > 0 {
|
||||
((count as f64 / max_activity as f64) * 40.0) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let bar = "█".repeat(bar_width);
|
||||
let bar_style = if hour >= 9 && hour < 17 {
|
||||
Style::default().fg(Color::Green) // Work hours
|
||||
} else if hour >= 6 && hour < 22 {
|
||||
Style::default().fg(Color::Yellow) // Waking hours
|
||||
} else {
|
||||
Style::default().fg(Color::Red) // Late night
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:02}:00 │ ", hour), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("{:>6} │ ", format_number(count)), Style::default().fg(Color::Cyan)),
|
||||
Span::styled(bar, bar_style),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Legend: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("■", Style::default().fg(Color::Green)),
|
||||
Span::raw(" Work (9-17) "),
|
||||
Span::styled("■", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" Day (6-22) "),
|
||||
Span::styled("■", Style::default().fg(Color::Red)),
|
||||
Span::raw(" Night"),
|
||||
]));
|
||||
|
||||
let hourly_title = format!(" Activity by Hour ({} days) ", app.days);
|
||||
let para = Paragraph::new(lines)
|
||||
.block(create_block(&hourly_title))
|
||||
.scroll((app.scroll_offset as u16, 0));
|
||||
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// REPORT VIEW
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub fn draw_report_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
let content = if app.report_content.is_empty() {
|
||||
"Press 'r' to generate a report, or 'c' to copy to clipboard.".to_string()
|
||||
} else {
|
||||
app.report_content.clone()
|
||||
};
|
||||
|
||||
let lines: Vec<Line> = content
|
||||
.lines()
|
||||
.skip(app.scroll_offset)
|
||||
.map(|line| {
|
||||
// Style headers
|
||||
if line.starts_with('#') {
|
||||
Line::from(Span::styled(line, Style::default().fg(Color::Cyan).bold()))
|
||||
} else if line.starts_with("│") || line.starts_with("├") || line.starts_with("└") || line.starts_with("┌") || line.starts_with("┐") || line.starts_with("┘") {
|
||||
Line::from(Span::styled(line, Style::default().fg(Color::DarkGray)))
|
||||
} else if line.contains(':') && !line.starts_with(' ') {
|
||||
Line::from(Span::styled(line, Style::default().fg(Color::Yellow)))
|
||||
} else {
|
||||
Line::from(line)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let para = Paragraph::new(lines)
|
||||
.block(create_block(" Report (c to copy) "))
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
f.render_widget(para, area);
|
||||
|
||||
let line_count = content.lines().count();
|
||||
render_scrollbar(f, area, app.scroll_offset, line_count);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn create_block(title: &str) -> Block<'_> {
|
||||
Block::default()
|
||||
.title(title)
|
||||
.title_style(Style::default().fg(Color::Cyan).bold())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
|
||||
fn create_percentage_bar(percentage: f64, _label: &str) -> Line<'static> {
|
||||
let filled = ((percentage / 100.0) * 30.0) as usize;
|
||||
let empty = 30 - filled;
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(" ["),
|
||||
Span::styled("█".repeat(filled), Style::default().fg(Color::Green)),
|
||||
Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw("]"),
|
||||
])
|
||||
}
|
||||
|
||||
fn render_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize) {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("▲"))
|
||||
.end_symbol(Some("▼"));
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::new(total).position(offset);
|
||||
|
||||
f.render_stateful_widget(
|
||||
scrollbar,
|
||||
area.inner(ratatui::layout::Margin { horizontal: 0, vertical: 1 }),
|
||||
&mut scrollbar_state,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user