use costroid_core::{
anomaly_multiple_phrase, format_money_usd, format_over_by_usd, Alert, BudgetScope, LimitKind,
};
use crate::app::color_of;
use crate::format::{percent, provider_label, reset_countdown};
use crate::glyph;
pub fn alert_step(alert: &Alert) -> u8 {
if alert.is_critical() {
8
} else {
4
}
}
pub fn draw(ui: &mut egui::Ui, alerts: &[Alert]) {
if alerts.is_empty() {
return;
}
ui.add_space(4.0);
for alert in alerts {
let step = alert_step(alert);
ui.horizontal(|ui| {
ui.add_space(8.0);
paint_severity_badge(ui, step);
ui.add_space(6.0);
ui.label(
egui::RichText::new(alert_sentence(alert))
.monospace()
.color(color_of(glyph::step_fill_color(step))),
);
});
}
ui.add_space(2.0);
}
fn alert_sentence(alert: &Alert) -> String {
match alert {
Alert::Quota {
tool,
kind,
fraction,
reset_in_seconds,
..
} => format!(
"{} {} limit at {}, resets in {}",
provider_label(*tool),
alert_window_phrase(*kind),
percent(*fraction),
reset_countdown(*reset_in_seconds),
),
Alert::Budget {
scope,
spent_usd,
target_usd,
over_by_usd,
} => format!(
"{} over by {}, spent {} of {}",
alert_budget_scope(scope),
format_over_by_usd(over_by_usd),
format_money_usd(spent_usd, true),
format_money_usd(target_usd, true),
),
Alert::Forecast {
projected_month_usd,
target_usd,
projected_over_by_usd,
} => format!(
"total budget projected over by {}, {} projected of {}",
format_over_by_usd(projected_over_by_usd),
format_money_usd(projected_month_usd, true),
format_money_usd(target_usd, true),
),
Alert::SpendSpike {
date,
value_usd,
baseline_median_usd,
magnitude,
} => {
let median_display = format_money_usd(baseline_median_usd, true);
let baseline_displays_zero = median_display == "~$0.00";
let comparison =
match anomaly_multiple_phrase(magnitude.as_ref(), baseline_displays_zero) {
Some(multiple) => format!("~{multiple}x your {median_display} norm"),
None => format!("well above your {median_display} norm"),
};
format!(
"daily spend spike: {} on {}, {comparison}",
format_money_usd(value_usd, true),
date.format("%b %d"),
)
}
}
}
fn alert_window_phrase(kind: LimitKind) -> &'static str {
match kind {
LimitKind::FiveHour => "5-hour",
LimitKind::Weekly => "weekly",
LimitKind::Daily => "daily",
LimitKind::Monthly => "monthly",
LimitKind::BillingCycle => "billing-cycle",
}
}
fn alert_budget_scope(scope: &BudgetScope) -> String {
match scope {
BudgetScope::Total => "total budget".to_string(),
BudgetScope::Tool(tool) => format!("{tool} budget"),
}
}
fn paint_severity_badge(ui: &mut egui::Ui, step: u8) {
let side = 16.0;
let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(side), egui::Sense::hover());
let severity = if step >= 8 { "critical" } else { "warning" };
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{severity} alert"))
});
let painter = ui.painter_at(rect);
let filled = glyph::dots_filled(step);
let mut lit = [false; 9];
for &idx in glyph::FILL_ORDER.iter().take(filled) {
lit[idx] = true;
}
let fill = color_of(glyph::step_fill_color(step));
let empty = color_of(glyph::EMPTY_DOT);
let radius = side * 0.095;
let cols = [0.22_f32, 0.5, 0.78];
let rows = [0.22_f32, 0.5, 0.78];
for (r, &ry) in rows.iter().enumerate() {
for (c, &cx) in cols.iter().enumerate() {
let center = rect.min + egui::vec2(cx * side, ry * side);
painter.circle_filled(center, radius, if lit[r * 3 + c] { fill } else { empty });
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use costroid_core::{AlertLevel, ProviderId};
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
match NaiveDate::from_ymd_opt(y, m, d) {
Some(date) => date,
None => panic!("valid date"),
}
}
fn warn_quota() -> Alert {
Alert::Quota {
tool: ProviderId::ClaudeCode,
kind: LimitKind::FiveHour,
level: AlertLevel::Warn,
fraction: 0.83,
reset_in_seconds: 3600,
}
}
fn over_budget() -> Alert {
Alert::Budget {
scope: BudgetScope::Total,
spent_usd: Default::default(),
target_usd: Default::default(),
over_by_usd: Default::default(),
}
}
#[test]
fn step_is_high_for_critical_and_mid_for_warn_advisory() {
let critical = Alert::Quota {
tool: ProviderId::ClaudeCode,
kind: LimitKind::Weekly,
level: AlertLevel::Critical,
fraction: 0.97,
reset_in_seconds: 3600,
};
let spike = Alert::SpendSpike {
date: date(2026, 6, 18),
value_usd: Default::default(),
baseline_median_usd: Default::default(),
magnitude: None,
};
assert_eq!(alert_step(&critical), 8);
assert_eq!(alert_step(&over_budget()), 8);
assert_eq!(alert_step(&warn_quota()), 4);
assert_eq!(
alert_step(&spike),
4,
"advisory spike is the mid heads-up tier"
);
}
#[test]
fn quota_sentence_is_quota_extension_never_money() {
let alert = Alert::Quota {
tool: ProviderId::ClaudeCode,
kind: LimitKind::Weekly,
level: AlertLevel::Critical,
fraction: 0.97,
reset_in_seconds: 2 * 86_400 + 6 * 3600,
};
let sentence = alert_sentence(&alert);
assert_eq!(sentence, "claude code weekly limit at 97%, resets in 2d 6h");
assert!(
!sentence.contains('$'),
"quota copy is never money: {sentence}"
);
}
#[test]
fn budget_sentence_structure_routes_through_core_money() {
let alert = Alert::Budget {
scope: BudgetScope::Tool("codex".to_string()),
spent_usd: Default::default(),
target_usd: Default::default(),
over_by_usd: Default::default(),
};
assert_eq!(
alert_sentence(&alert),
"codex budget over by <$0.01, spent ~$0.00 of ~$0.00"
);
}
#[test]
fn spend_spike_sentence_falls_back_to_well_above_over_a_zero_baseline() {
let alert = Alert::SpendSpike {
date: date(2026, 6, 18),
value_usd: Default::default(),
baseline_median_usd: Default::default(),
magnitude: None,
};
assert_eq!(
alert_sentence(&alert),
"daily spend spike: ~$0.00 on Jun 18, well above your ~$0.00 norm"
);
}
#[test]
fn headless_draw_does_not_panic() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let alerts = [warn_quota(), over_budget()];
for slice in [&alerts[..], &[]] {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
draw(ui, slice);
});
}
}
}