use chrono::Datelike;
use costroid_core::{
forecast_daily_fractions, format_money_usd, ForecastView, LimitKind, QuotaEta, QuotaEtaOutcome,
QuotaEtaUnavailable, SpendForecast,
};
use crate::app::{color_of, ASH, BONE, DATA_CYAN};
use crate::format::provider_label;
const FORECAST_SCOPE_LINE: &str = "scope: API-lane spend this month + quota burn (estimated)";
const FORECAST_NO_USAGE: &str = "no API usage recorded - nothing to forecast yet";
const FORECAST_ESTIMATE_NOTE: &str =
"figures are local estimates (your tokens x current prices), projected - not the vendor invoice.";
pub fn draw(ui: &mut egui::Ui, view: &ForecastView) {
draw_header(ui, view);
text_line(ui, FORECAST_SCOPE_LINE, ASH);
if view.no_api_usage {
text_line(ui, FORECAST_NO_USAGE, ASH);
} else {
for line in spend_lines(view) {
text_line(ui, &line, BONE);
}
let fractions = forecast_daily_fractions(&view.daily_actuals);
if !fractions.is_empty() {
ui.add_space(2.0);
paint_sparkline(ui, &fractions);
}
}
ui.add_space(2.0);
draw_quota_section(ui, view);
ui.add_space(2.0);
text_line(ui, FORECAST_ESTIMATE_NOTE, ASH);
}
fn draw_header(ui: &mut egui::Ui, view: &ForecastView) {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new("forecast")
.monospace()
.color(color_of(ASH)),
);
if let Some(money) = header_money(view) {
ui.add_space(8.0);
ui.label(
egui::RichText::new(money)
.monospace()
.strong()
.color(color_of(BONE)),
);
}
});
}
fn header_money(view: &ForecastView) -> Option<String> {
if view.no_api_usage {
return None;
}
let amount = match &view.spend {
SpendForecast::Projected {
projected_month_usd,
..
} => projected_month_usd,
SpendForecast::InsufficientData {
spend_to_date_usd, ..
} => spend_to_date_usd,
};
Some(format_money_usd(amount, true))
}
fn spend_lines(view: &ForecastView) -> Vec<String> {
match &view.spend {
SpendForecast::Projected {
projected_month_usd,
spend_to_date_usd,
days_elapsed,
days_in_month,
} => vec![
format!(
"projected {} by {} (estimated)",
format_money_usd(projected_month_usd, true),
month_end_label(view, *days_in_month),
),
format!(
"spend so far {} over {} of {} days (estimated)",
format_money_usd(spend_to_date_usd, true),
days_elapsed,
days_in_month,
),
],
SpendForecast::InsufficientData {
spend_to_date_usd,
days_elapsed,
days_in_month,
min_days,
} => vec![
format!(
"insufficient data to project - {} of {} days elapsed (need {}+) (estimated)",
days_elapsed, days_in_month, min_days,
),
format!(
"spend so far {} over {} of {} days (estimated)",
format_money_usd(spend_to_date_usd, true),
days_elapsed,
days_in_month,
),
],
}
}
fn month_end_label(view: &ForecastView, days_in_month: u32) -> String {
let today = view.generated_at.date_naive();
match chrono::NaiveDate::from_ymd_opt(today.year(), today.month(), days_in_month) {
Some(end) => end.format("%b %d").to_string(),
None => "month end".to_string(),
}
}
fn draw_quota_section(ui: &mut egui::Ui, view: &ForecastView) {
if view.quota_etas.is_empty() {
text_line(ui, "quota: no quota windows tracked", ASH);
return;
}
text_line(ui, "quota:", ASH);
for eta in &view.quota_etas {
text_line(ui, "a_line(eta), ASH);
}
}
fn quota_line(eta: &QuotaEta) -> String {
let window = format!("{} {}", provider_label(eta.tool), kind_word(eta.kind));
match &eta.outcome {
QuotaEtaOutcome::ProjectedHit { at, .. } => {
format!(
"{window}: projected to hit ~{} (UTC, estimated)",
at.format("%A")
)
}
QuotaEtaOutcome::ResetsFirst { .. } => {
format!("{window}: resets before you hit it (estimated)")
}
QuotaEtaOutcome::Unavailable { reason } => {
format!("{window}: ETA unavailable ({})", unavailable_text(*reason))
}
}
}
fn kind_word(kind: LimitKind) -> &'static str {
match kind {
LimitKind::FiveHour => "5h",
LimitKind::Weekly => "weekly",
LimitKind::Daily => "daily",
LimitKind::Monthly => "monthly",
LimitKind::BillingCycle => "billing cycle",
}
}
fn unavailable_text(reason: QuotaEtaUnavailable) -> &'static str {
match reason {
QuotaEtaUnavailable::ReadingNotProjectable => "no fresh verified usage reading",
QuotaEtaUnavailable::WindowJustStarted => "window just started",
}
}
fn paint_sparkline(ui: &mut egui::Ui, fractions: &[f64]) {
const ROWS: usize = 4;
let columns = fractions.len().max(1);
let cell_w = 6.0_f32;
let width = (columns as f32 * cell_w).min(ui.available_width().max(cell_w));
let height = 22.0_f32;
ui.horizontal(|ui| {
ui.add_space(8.0);
let (rect, response) =
ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
let days = fractions.len();
response.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Image,
true,
format!("daily api spend sparkline, last {days} days"),
)
});
let painter = ui.painter_at(rect);
let lit = color_of(DATA_CYAN);
let dim = color_of(crate::glyph::EMPTY_DOT);
let col_w = rect.width() / columns as f32;
let row_h = rect.height() / ROWS as f32;
let radius = (col_w.min(row_h) * 0.5 * 0.62).max(0.5);
for (index, &fraction) in fractions.iter().enumerate() {
let height_dots = spark_height(fraction, ROWS);
let cx = rect.left() + (index as f32 + 0.5) * col_w;
for row in 0..ROWS {
let cy = rect.bottom() - (row as f32 + 0.5) * row_h;
let on = row < height_dots;
painter.circle_filled(egui::pos2(cx, cy), radius, if on { lit } else { dim });
}
}
});
}
fn spark_height(fraction: f64, rows: usize) -> usize {
if !fraction.is_finite() || fraction <= 0.0 {
return 0;
}
let raw = (fraction * rows as f64).round() as usize;
raw.clamp(1, rows)
}
fn text_line(ui: &mut egui::Ui, text: &str, ink: [u8; 4]) {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new(text).monospace().color(color_of(ink)));
});
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, NaiveDate, Utc};
use costroid_core::{ForecastDay, ProviderId};
fn ts() -> DateTime<Utc> {
match DateTime::from_timestamp(1_900_000_000, 0) {
Some(dt) => dt,
None => panic!("valid ts"),
}
}
fn day(d: u32) -> ForecastDay {
ForecastDay {
date: match NaiveDate::from_ymd_opt(2026, 6, d) {
Some(date) => date,
None => panic!("valid date"),
},
spent_usd: Default::default(),
}
}
fn base(spend: SpendForecast, no_api: bool, etas: Vec<QuotaEta>) -> ForecastView {
ForecastView {
generated_at: ts(),
no_api_usage: no_api,
spend,
daily_actuals: vec![day(1), day(2), day(3)],
quota_etas: etas,
}
}
#[test]
fn spark_height_min_visibility_and_clamp() {
assert_eq!(spark_height(0.0, 4), 0);
assert_eq!(spark_height(0.01, 4), 1, "any nonzero lights >= 1 dot");
assert_eq!(spark_height(1.0, 4), 4);
assert_eq!(spark_height(f64::NAN, 4), 0);
}
#[test]
fn quota_line_degrades_honestly() {
let projectable = QuotaEta {
tool: ProviderId::ClaudeCode,
kind: LimitKind::Weekly,
outcome: QuotaEtaOutcome::Unavailable {
reason: QuotaEtaUnavailable::ReadingNotProjectable,
},
};
let line = quota_line(&projectable);
assert!(line.contains("ETA unavailable"), "line: {line}");
assert!(line.contains("no fresh verified usage reading"));
}
#[test]
fn header_money_is_none_when_no_api_usage() {
let view = base(
SpendForecast::InsufficientData {
spend_to_date_usd: Default::default(),
days_elapsed: 0,
days_in_month: 30,
min_days: 3,
},
true,
Vec::new(),
);
assert!(header_money(&view).is_none());
}
#[test]
fn headless_draw_covers_projected_insufficient_and_empty() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let projected = base(
SpendForecast::Projected {
projected_month_usd: Default::default(),
spend_to_date_usd: Default::default(),
days_elapsed: 9,
days_in_month: 30,
},
false,
vec![QuotaEta {
tool: ProviderId::ClaudeCode,
kind: LimitKind::Weekly,
outcome: QuotaEtaOutcome::Unavailable {
reason: QuotaEtaUnavailable::WindowJustStarted,
},
}],
);
let insufficient = base(
SpendForecast::InsufficientData {
spend_to_date_usd: Default::default(),
days_elapsed: 1,
days_in_month: 30,
min_days: 3,
},
false,
Vec::new(),
);
let mut empty = insufficient.clone();
empty.no_api_usage = true;
empty.daily_actuals = Vec::new();
for v in [projected, insufficient, empty] {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
draw(ui, &v);
});
}
}
}