Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 213 additions & 109 deletions src/analytics/gain.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
//! Shows users how many tokens RTK has saved them over time.

use crate::core::display_helpers::{format_duration, print_period_table};
use crate::core::tracking::{DayStats, MonthStats, Tracker, WeekStats};
use crate::core::tracking::{DayStats, GainSummary, MonthStats, SessionStat, Tracker, WeekStats};
use crate::core::utils::format_tokens;
use crate::hooks::hook_check;
use anyhow::{Context, Result};
use chrono::Local;
use chrono::{Local, Utc};
use colored::Colorize;
use serde::Serialize;
use std::io::IsTerminal;
use std::path::PathBuf;

#[allow(clippy::too_many_arguments)]
pub fn run(
project: bool, // added: per-project scope flag
project: bool,
graph: bool,
history: bool,
session: Option<&str>,
quota: bool,
tier: &str,
daily: bool,
Expand Down Expand Up @@ -47,6 +48,22 @@ pub fn run(
return show_failures(&tracker);
}

if let Some(session_filter) = session {
if session_filter.is_empty() {
// bare --session: show session list
let sessions = tracker
.get_by_session(None)
.context("Failed to load session data from database")?;
return show_session_view(&sessions, None);
} else {
// --session <id>: show full gain detail scoped to this session
let summary = tracker
.get_summary_for_session(session_filter)
.context("Failed to load session summary from database")?;
return show_session_detail(session_filter, &summary);
}
}

// Handle export formats
match format {
"json" => {
Expand Down Expand Up @@ -98,28 +115,7 @@ pub fn run(
}
println!();

// added: KPI-style aligned output
print_kpi("Total commands", summary.total_commands.to_string());
print_kpi("Input tokens", format_tokens(summary.total_input));
print_kpi("Output tokens", format_tokens(summary.total_output));
print_kpi(
"Tokens saved",
format!(
"{} ({:.1}%)",
format_tokens(summary.total_saved),
summary.avg_savings_pct
),
);
print_kpi(
"Total exec time",
format!(
"{} (avg {})",
format_duration(summary.total_time_ms),
format_duration(summary.avg_time_ms)
),
);
print_efficiency_meter(summary.avg_savings_pct);
println!();
print_summary_kpis(&summary);

// Warn about hook issues that silently kill savings (stderr, not stdout)
match hook_check::status() {
Expand Down Expand Up @@ -147,90 +143,7 @@ pub fn run(
eprintln!();
}

if !summary.by_command.is_empty() {
// added: styled section header
println!("{}", styled("By Command", true));

// added: dynamic column widths for clean alignment
let cmd_width = 24usize;
let impact_width = 10usize;
let count_width = summary
.by_command
.iter()
.map(|(_, count, _, _, _)| count.to_string().len())
.max()
.unwrap_or(5)
.max(5);
let saved_width = summary
.by_command
.iter()
.map(|(_, _, saved, _, _)| format_tokens(*saved).len())
.max()
.unwrap_or(5)
.max(5);
let time_width = summary
.by_command
.iter()
.map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len())
.max()
.unwrap_or(6)
.max(6);

let table_width = 3
+ 2
+ cmd_width
+ 2
+ count_width
+ 2
+ saved_width
+ 2
+ 6
+ 2
+ time_width
+ 2
+ impact_width;
println!("{}", "─".repeat(table_width));
println!(
"{:>3} {:<cmd_width$} {:>count_width$} {:>saved_width$} {:>6} {:>time_width$} {:<impact_width$}",
"#", "Command", "Count", "Saved", "Avg%", "Time", "Impact",
cmd_width = cmd_width, count_width = count_width,
saved_width = saved_width, time_width = time_width,
impact_width = impact_width
);
println!("{}", "─".repeat(table_width));

let max_saved = summary
.by_command
.iter()
.map(|(_, _, saved, _, _)| *saved)
.max()
.unwrap_or(1);

for (idx, (cmd, count, saved, pct, avg_time)) in summary.by_command.iter().enumerate() {
let row_idx = format!("{:>2}.", idx + 1);
let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); // added: colored command
let count_cell = format!("{:>count_width$}", count, count_width = count_width);
let saved_cell = format!(
"{:>saved_width$}",
format_tokens(*saved),
saved_width = saved_width
);
let pct_plain = format!("{:>6}", format!("{pct:.1}%"));
let pct_cell = colorize_pct_cell(*pct, &pct_plain); // added: color-coded percentage
let time_cell = format!(
"{:>time_width$}",
format_duration(*avg_time),
time_width = time_width
);
let impact = mini_bar(*saved, max_saved, impact_width); // added: impact bar
println!(
"{} {} {} {} {} {} {}",
row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact
);
}
println!("{}", "─".repeat(table_width));
println!();
}
print_by_command_table(&summary.by_command);

if graph && !summary.by_day.is_empty() {
println!("{}", styled("Daily Savings (last 30 days)", true)); // added: styled header
Expand Down Expand Up @@ -684,6 +597,197 @@ fn check_rtk_disabled_bypass() -> Option<String> {
}
}

fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<()> {
if sessions.is_empty() {
if let Some(f) = filter {
println!("No session found matching '{f}'.");
} else {
println!("No session data yet.");
println!(
"Session tracking requires CLAUDE_CODE_SESSION_ID to be set (Claude Code ≥ v2.x)."
);
}
return Ok(());
}

let title = match filter {
Some(f) => format!("RTK Session Savings — prefix '{f}'"),
None => "RTK Session Savings".to_string(),
};
println!("{}", styled(&title, true));
println!("{}", "─".repeat(62));
println!(
"{:<10} {:<12} {:>5} {:>8} {:>6}",
"Session", "Last Seen", "Cmds", "Saved", "Avg%"
);
println!("{}", "─".repeat(62));

for s in sessions {
let age = {
let secs = (Utc::now() - s.last_seen).num_seconds().max(0) as u64;
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
};
let short_id: String = s.session_id.chars().take(8).collect();
let pct_cell = colorize_pct_cell(s.avg_savings_pct, &format!("{:.1}%", s.avg_savings_pct));
println!(
"{:<10} {:<12} {:>5} {:>8} {}",
short_id,
age,
s.commands,
format_tokens(s.saved_tokens),
pct_cell,
);
}

println!("{}", "─".repeat(62));
let total_cmds: usize = sessions.iter().map(|s| s.commands).sum();
let total_saved: usize = sessions.iter().map(|s| s.saved_tokens).sum();
println!(
"Total: {} sessions • {} commands • {} saved",
sessions.len(),
total_cmds,
format_tokens(total_saved),
);
Ok(())
}

fn print_summary_kpis(summary: &GainSummary) {
print_kpi("Total commands", summary.total_commands.to_string());
print_kpi("Input tokens", format_tokens(summary.total_input));
print_kpi("Output tokens", format_tokens(summary.total_output));
print_kpi(
"Tokens saved",
format!(
"{} ({:.1}%)",
format_tokens(summary.total_saved),
summary.avg_savings_pct
),
);
print_kpi(
"Total exec time",
format!(
"{} (avg {})",
format_duration(summary.total_time_ms),
format_duration(summary.avg_time_ms)
),
);
print_efficiency_meter(summary.avg_savings_pct);
println!();
}

fn print_by_command_table(by_command: &[(String, usize, usize, f64, u64)]) {
if by_command.is_empty() {
return;
}
println!("{}", styled("By Command", true));

let cmd_width = 24usize;
let impact_width = 10usize;
let count_width = by_command
.iter()
.map(|(_, count, _, _, _)| count.to_string().len())
.max()
.unwrap_or(5)
.max(5);
let saved_width = by_command
.iter()
.map(|(_, _, saved, _, _)| format_tokens(*saved).len())
.max()
.unwrap_or(5)
.max(5);
let time_width = by_command
.iter()
.map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len())
.max()
.unwrap_or(6)
.max(6);

let table_width = 3
+ 2
+ cmd_width
+ 2
+ count_width
+ 2
+ saved_width
+ 2
+ 6
+ 2
+ time_width
+ 2
+ impact_width;
println!("{}", "─".repeat(table_width));
println!(
"{:>3} {:<cmd_width$} {:>count_width$} {:>saved_width$} {:>6} {:>time_width$} {:<impact_width$}",
"#", "Command", "Count", "Saved", "Avg%", "Time", "Impact",
cmd_width = cmd_width, count_width = count_width,
saved_width = saved_width, time_width = time_width,
impact_width = impact_width
);
println!("{}", "─".repeat(table_width));

let max_saved = by_command
.iter()
.map(|(_, _, saved, _, _)| *saved)
.max()
.unwrap_or(1);

for (idx, (cmd, count, saved, pct, avg_time)) in by_command.iter().enumerate() {
let row_idx = format!("{:>2}.", idx + 1);
let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width));
let count_cell = format!("{:>count_width$}", count, count_width = count_width);
let saved_cell = format!(
"{:>saved_width$}",
format_tokens(*saved),
saved_width = saved_width
);
let pct_plain = format!("{:>6}", format!("{pct:.1}%"));
let pct_cell = colorize_pct_cell(*pct, &pct_plain);
let time_cell = format!(
"{:>time_width$}",
format_duration(*avg_time),
time_width = time_width
);
let impact = mini_bar(*saved, max_saved, impact_width);
println!(
"{} {} {} {} {} {} {}",
row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact
);
}
println!("{}", "─".repeat(table_width));
println!();
}

fn show_session_detail(session_filter: &str, summary: &GainSummary) -> Result<()> {
if summary.total_commands == 0 {
println!("No data found for session '{session_filter}'.");
return Ok(());
}

println!(
"{}",
styled(
&format!("RTK Token Savings — Session {session_filter}"),
true
)
);
println!("{}", "═".repeat(60));
println!();

print_summary_kpis(summary);

print_by_command_table(&summary.by_command);

Ok(())
}

fn show_failures(tracker: &Tracker) -> Result<()> {
let summary = tracker
.get_parse_failure_summary()
Expand Down
Loading