use chrono::NaiveDateTime;
use ratatui::{
style::Modifier,
text::{Line, Span},
};
use super::{
panel_text::{indented_wrapped_lines, truncate_to},
render::display_width,
theme::Theme,
usage_cost::format_token_count as count,
};
use crate::usage::report::{
SpendGroup, SpendRange, SpendReport, SpendTotals, Timeline, TimelineUnit, Valuation,
};
const INDENT: &str = " ";
const COST_WIDTH: usize = 12;
const TOKENS_WIDTH: usize = 9;
const MAX_TABLE_ROWS: usize = 5;
const MAX_BODY_WIDTH: usize = 72;
const MAX_BUCKET_COLUMNS: usize = 6;
const SPARK_LEVELS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
fn range_label(range: SpendRange) -> &'static str {
match range {
SpendRange::AllTime => "All time",
SpendRange::Last30Days => "30 days",
SpendRange::Last7Days => "7 days",
SpendRange::Today => "Today",
}
}
pub(super) fn range_tabs_line(selected: SpendRange) -> Line<'static> {
let mut spans = vec![Span::raw(INDENT)];
for (index, range) in SpendRange::ALL.into_iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let label = format!(" {} ", range_label(range));
let style = if range == selected {
Theme::brand().add_modifier(Modifier::REVERSED)
} else {
Theme::dim()
};
spans.push(Span::styled(label, style));
}
Line::from(spans)
}
pub(super) fn spend_body_lines(
range: SpendRange,
report: &SpendReport,
width: usize,
) -> Vec<Line<'static>> {
let width = width.min(MAX_BODY_WIDTH);
if report.totals.requests == 0 {
let message = match range {
SpendRange::AllTime => {
"No model requests recorded yet. Rho writes one row per request to the usage ledger."
}
SpendRange::Today | SpendRange::Last7Days | SpendRange::Last30Days => {
"No model requests in this range. Press Tab for a longer range."
}
};
return indented_wrapped_lines(message, INDENT.len(), width, Theme::dim());
}
let mut lines = summary_lines(report, width);
lines.push(Line::default());
lines.extend(timeline_lines(&report.timeline, width));
let name_width = width
.saturating_sub(INDENT.len() + COST_WIDTH + TOKENS_WIDTH)
.max(1);
for (title, groups) in [
("Models", &report.models),
("Providers", &report.providers),
("Purpose", &report.purposes),
] {
lines.push(Line::default());
lines.push(section_heading(title, width));
let (shown, rest) = fold_tail(groups, MAX_TABLE_ROWS);
for group in shown {
lines.push(table_row(name_width, &group.name, &group.totals));
}
if let Some((hidden, totals)) = rest {
lines.push(
table_row(name_width, &format!("{hidden} more"), &totals).patch_style(Theme::dim()),
);
}
}
lines
}
fn summary_lines(report: &SpendReport, width: usize) -> Vec<Line<'static>> {
let totals = &report.totals;
let value = money(totals.equivalent_usd_micros());
let label = "Equivalent API cost";
let pad = width
.saturating_sub(display_width(label))
.saturating_sub(display_width(&value));
let headline = Line::from(vec![
Span::styled(label, Theme::text_strong()),
Span::raw(" ".repeat(pad)),
Span::styled(value, Theme::brand()),
]);
let mut detail = vec![
format!("{} actual", money(totals.actual_usd_micros)),
format!("{} computed", money(totals.computed_usd_micros)),
plural(totals.requests, "request"),
];
if totals.unpriced_requests > 0 {
detail.push(format!("{} unpriced", count(totals.unpriced_requests)));
}
let mut text = String::from(INDENT);
for (index, part) in detail.iter().enumerate() {
let separator = if index == 0 { "" } else { " · " };
if display_width(&text) + display_width(separator) + display_width(part) > width {
break;
}
text.push_str(separator);
text.push_str(part);
}
vec![headline, Line::from(Span::styled(text, Theme::dim()))]
}
fn timeline_lines(timeline: &Timeline, width: usize) -> Vec<Line<'static>> {
let values = &timeline.equivalent_usd_micros;
let columns = chart_columns(values, width.saturating_sub(INDENT.len()));
let peak = values.iter().copied().max().unwrap_or(0);
let mut chart = vec![Span::raw(INDENT)];
for value in &columns {
let (glyph, style) = if *value == 0 {
("▁", Theme::dim())
} else {
let level = ((*value as u128 * 8).div_ceil(peak as u128) as usize).clamp(1, 8);
(SPARK_LEVELS[level - 1], Theme::accent())
};
chart.push(Span::styled(glyph, style));
}
let per = match timeline.unit {
TimelineUnit::Hour => "hour",
TimelineUnit::Day => "day",
};
let start = bucket_label(timeline.unit, timeline.start);
let end = if peak > 0 {
format!("peak {} / {per}", money(peak))
} else {
String::new()
};
let gap = columns
.len()
.saturating_sub(display_width(&start))
.saturating_sub(display_width(&end))
.max(2);
let axis = format!("{INDENT}{start}{}{end}", " ".repeat(gap));
vec![
Line::from(chart),
Line::from(Span::styled(truncate_to(&axis, width), Theme::dim())),
]
}
fn chart_columns(values: &[u64], width: usize) -> Vec<u64> {
let count = width.min(values.len() * MAX_BUCKET_COLUMNS);
(0..count)
.map(|column| {
let from = column * values.len() / count;
let to = ((column + 1) * values.len() / count).max(from + 1);
values[from..to].iter().copied().max().unwrap_or(0)
})
.collect()
}
fn bucket_label(unit: TimelineUnit, start: NaiveDateTime) -> String {
match unit {
TimelineUnit::Hour => start.format("%H:%M").to_string(),
TimelineUnit::Day => start.format("%b %-d").to_string(),
}
}
fn section_heading(title: &str, width: usize) -> Line<'static> {
Line::from(Span::styled(
truncate_to(title, width),
Theme::text_strong(),
))
}
fn fold_tail(groups: &[SpendGroup], max: usize) -> (&[SpendGroup], Option<(usize, SpendTotals)>) {
if groups.len() <= max + 1 {
return (groups, None);
}
let (shown, hidden) = groups.split_at(max);
let mut totals = SpendTotals::default();
for group in hidden {
totals += group.totals;
}
(shown, Some((hidden.len(), totals)))
}
fn table_row(name_width: usize, name: &str, totals: &SpendTotals) -> Line<'static> {
let name = truncate_to(name, name_width.saturating_sub(1));
let pad = name_width.saturating_sub(display_width(&name));
let (cost, cost_style) = match totals.valuation() {
Valuation::Local => ("local".to_owned(), Theme::dim()),
Valuation::Unpriced => ("—".to_owned(), Theme::dim()),
Valuation::Usd(micros) => (money(micros), Theme::text()),
};
Line::from(vec![
Span::raw(INDENT),
Span::styled(name, Theme::text()),
Span::raw(" ".repeat(pad)),
Span::styled(format!("{cost:>COST_WIDTH$}"), cost_style),
Span::styled(
format!("{:>TOKENS_WIDTH$}", count(totals.tokens)),
Theme::dim(),
),
])
}
fn money(micros: u64) -> String {
if micros > 0 && micros < 10_000 {
return "<$0.01".to_owned();
}
let cents = micros.saturating_add(5_000) / 10_000;
format!("${}.{:02}", grouped(cents / 100), cents % 100)
}
fn grouped(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
fn plural(value: u64, noun: &str) -> String {
let suffix = if value == 1 { "" } else { "s" };
format!("{} {noun}{suffix}", count(value))
}
#[cfg(test)]
#[path = "spend_view_tests.rs"]
mod tests;