Skip to main content

lean_ctx/core/stats/format/
views.rs

1//! Secondary gain views: `--graph` sparkline, `--daily` table, `--json`.
2
3use super::util::{active_theme, day_total_saved, format_big, format_num, usd_estimate};
4use crate::core::theme;
5
6use super::super::model::CostModel;
7
8/// Renders a 30-day token savings bar chart with sparkline.
9pub fn format_gain_graph() -> String {
10    let theme = active_theme();
11    let store = crate::core::stats::load();
12    let rst = theme::rst();
13    let bold = theme::bold();
14    let dim = theme::dim();
15
16    if store.daily.is_empty() {
17        return format!(
18            "{dim}No daily data yet.{rst} Use lean-ctx for a few days to see the graph."
19        );
20    }
21
22    let cm = CostModel::default();
23    let days: Vec<_> = store
24        .daily
25        .iter()
26        .rev()
27        .take(30)
28        .collect::<Vec<_>>()
29        .into_iter()
30        .rev()
31        .collect();
32
33    let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
34
35    let max_saved = *savings.iter().max().unwrap_or(&1);
36    let max_saved = max_saved.max(1);
37
38    let bar_width = 36;
39    let mut out = Vec::new();
40
41    out.push(String::new());
42    out.push(format!(
43        "  {icon} {title}  {dim}Token Savings Graph (last 30 days){rst}",
44        icon = theme.header_icon(),
45        title = theme.brand_title(),
46    ));
47    out.push(format!("  {ln}", ln = theme.border_line(58)));
48    out.push(format!(
49        "  {dim}{:>58}{rst}",
50        format!("peak: {}", format_big(max_saved))
51    ));
52    out.push(String::new());
53
54    for (i, day) in days.iter().enumerate() {
55        let saved = savings[i];
56        let ratio = saved as f64 / max_saved as f64;
57        let bar = theme::pad_right(&theme.gradient_bar(ratio, bar_width), bar_width);
58
59        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
60        let pct = if day.input_tokens > 0 {
61            input_saved as f64 / day.input_tokens as f64 * 100.0
62        } else {
63            0.0
64        };
65        let date_short = day.date.get(5..).unwrap_or(&day.date);
66
67        out.push(format!(
68            "  {m}{date_short}{rst} {brd}│{rst} {bar} {bold}{:>6}{rst} {dim}{pct:.0}%{rst}",
69            format_big(saved),
70            m = theme.muted.fg(),
71            brd = theme.border.fg(),
72        ));
73    }
74
75    let total_saved: u64 = savings.iter().sum();
76    let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
77    let spark = theme.gradient_sparkline(&savings);
78
79    out.push(String::new());
80    out.push(format!("  {ln}", ln = theme.border_line(58)));
81    out.push(format!(
82        "  {spark}  {bold}{txt}{}{rst} saved across {bold}{}{rst} commands",
83        format_big(total_saved),
84        format_num(total_cmds),
85        txt = theme.text.fg(),
86    ));
87    out.push(String::new());
88
89    out.join("\n")
90}
91
92/// Renders a daily breakdown table of token savings with totals.
93#[allow(clippy::many_single_char_names)]
94pub fn format_gain_daily() -> String {
95    let theme = active_theme();
96    let store = crate::core::stats::load();
97    let rst = theme::rst();
98    let bold = theme::bold();
99    let dim = theme::dim();
100
101    if store.daily.is_empty() {
102        return format!("{dim}No daily data yet.{rst}");
103    }
104
105    let mut out = Vec::new();
106    let w = 76;
107
108    let side = theme.box_side();
109    let daily_box = |content: &str| -> String {
110        let padded = theme::pad_right(content, w);
111        format!("  {side}{padded}{side}")
112    };
113
114    out.push(String::new());
115    out.push(format!(
116        "  {icon} {title}  {dim}Daily Breakdown{rst}",
117        icon = theme.header_icon(),
118        title = theme.brand_title(),
119    ));
120    out.push(format!("  {}", theme.box_top(w)));
121    let hdr = format!(
122        " {bold}{txt}{:<12} {:>6}  {:>10}  {:>10}  {:>7}  {:>8}  {:>8}{rst}",
123        "Date",
124        "Cmds",
125        "Input",
126        "Saved",
127        "Rate",
128        "USD",
129        "Ver",
130        txt = theme.text.fg(),
131    );
132    out.push(daily_box(&hdr));
133    out.push(format!("  {}", theme.box_mid(w)));
134
135    let days: Vec<_> = store
136        .daily
137        .iter()
138        .rev()
139        .take(30)
140        .collect::<Vec<_>>()
141        .into_iter()
142        .rev()
143        .cloned()
144        .collect();
145
146    let cm = CostModel::default();
147    for day in &days {
148        let saved = day_total_saved(day, &cm);
149        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
150        let pct = if day.input_tokens > 0 {
151            input_saved as f64 / day.input_tokens as f64 * 100.0
152        } else {
153            0.0
154        };
155        let pc = theme.pct_color(pct);
156        let usd = usd_estimate(saved);
157        let ver = if day.version.is_empty() {
158            "—".to_string()
159        } else {
160            format!("v{}", day.version)
161        };
162        let row = format!(
163            " {m}{:<12}{rst} {:>6}  {:>10}  {pc}{bold}{:>10}{rst}  {pc}{:>6.1}%{rst}  {dim}{:>8}{rst}  {dim}{:>8}{rst}",
164            day.date,
165            day.commands,
166            format_big(day.input_tokens),
167            format_big(saved),
168            pct,
169            usd,
170            ver,
171            m = theme.muted.fg(),
172        );
173        out.push(daily_box(&row));
174    }
175
176    let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
177    let total_saved: u64 = store
178        .daily
179        .iter()
180        .map(|day| day_total_saved(day, &cm))
181        .sum();
182    let total_pct = if total_input > 0 {
183        let input_saved: u64 = store
184            .daily
185            .iter()
186            .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
187            .sum();
188        input_saved as f64 / total_input as f64 * 100.0
189    } else {
190        0.0
191    };
192    let total_usd = usd_estimate(total_saved);
193    let sc = theme.success.fg();
194
195    out.push(format!("  {}", theme.box_mid(w)));
196    let total_row = format!(
197        " {bold}{txt}{:<12}{rst} {:>6}  {:>10}  {sc}{bold}{:>10}{rst}  {sc}{bold}{:>6.1}%{rst}  {bold}{:>8}{rst}  {bold}{:>8}{rst}",
198        "TOTAL",
199        format_num(store.total_commands),
200        format_big(total_input),
201        format_big(total_saved),
202        total_pct,
203        total_usd,
204        "",
205        txt = theme.text.fg(),
206    );
207    out.push(daily_box(&total_row));
208    out.push(format!("  {}", theme.box_bottom(w)));
209
210    let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
211    let spark = theme.gradient_sparkline(&daily_savings);
212    out.push(format!("  {dim}Trend:{rst} {spark}"));
213    out.push(String::new());
214
215    out.join("\n")
216}
217
218/// Returns the full stats store as pretty-printed JSON.
219pub fn format_gain_json() -> String {
220    let store = crate::core::stats::load();
221    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
222}