use crate::providers::QuotaWindow;
use chrono::Utc;
use ratatui::prelude::*;
pub fn currency_window(window_type: &str) -> Option<(&'static str, f64)> {
let lower = window_type.to_ascii_lowercase();
if lower == "payg_balance" {
return Some(("$", 100.0));
}
if lower.ends_with("_usd") {
return Some(("$", 10_000.0));
}
if lower.ends_with("_cny") {
return Some(("¥", 10_000.0));
}
None
}
pub fn currency_bar_scale(window_type: &str) -> Option<(&'static str, f64)> {
match window_type {
"monthly_allowance" => Some(("$", 10_000.0)),
_ => None,
}
}
pub fn time_elapsed_fraction(w: &QuotaWindow) -> Option<f64> {
let reset = w.reset_at?;
let period = w.period_seconds?;
if period <= 0 {
return None;
}
let remaining = (reset - Utc::now()).num_seconds();
if remaining <= 0 {
return Some(1.0);
}
if remaining >= period {
return Some(0.0);
}
Some(((period - remaining) as f64 / period as f64).clamp(0.0, 1.0))
}
pub fn bar_color(used_pct: f64) -> Color {
if used_pct >= 0.90 {
Color::Red
} else if used_pct >= 0.75 {
Color::Yellow
} else {
Color::Green
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Cell {
OnPace,
Overspend,
Slack,
Future,
}
const OVERSPEND_RGB: Color = Color::Rgb(255, 140, 0);
fn cells_for(used_pct: f64, time_elapsed_pct: Option<f64>, width: usize) -> Vec<Cell> {
let used_cells = ((used_pct.clamp(0.0, 1.0)) * width as f64).round() as usize;
let time_cells =
time_elapsed_pct.map(|t| ((t.clamp(0.0, 1.0)) * width as f64).round() as usize);
(0..width)
.map(|i| {
let is_used = i < used_cells;
let past_time = match time_cells {
Some(t) => i < t,
None => is_used,
};
match (is_used, past_time) {
(true, true) => Cell::OnPace,
(true, false) => Cell::Overspend,
(false, true) => Cell::Slack,
(false, false) => Cell::Future,
}
})
.collect()
}
pub fn build<'a>(
used_pct: f64,
time_elapsed_pct: Option<f64>,
width: u16,
base_color: Color,
) -> Vec<Span<'a>> {
build_labeled(used_pct, time_elapsed_pct, width, base_color, "")
}
pub fn build_labeled<'a>(
used_pct: f64,
time_elapsed_pct: Option<f64>,
width: u16,
base_color: Color,
overlay: &str,
) -> Vec<Span<'a>> {
let w = width as usize;
if w == 0 {
return Vec::new();
}
let cells = cells_for(used_pct, time_elapsed_pct, w);
let overlay_chars: Vec<char> = overlay.chars().collect();
let overlay_len = overlay_chars.len().min(w);
let used_cells = ((used_pct.clamp(0.0, 1.0)) * w as f64).round() as usize;
let overlay_start = if overlay_len == 0 {
w
} else {
let half = overlay_len / 2;
let raw = used_cells.saturating_sub(half);
raw.min(w - overlay_len)
};
let overlay_end = overlay_start + overlay_len;
let base_char_for = |c: Cell| match c {
Cell::OnPace | Cell::Overspend => '█',
Cell::Slack => '▒',
Cell::Future => '░',
};
let base_style_for = |c: Cell| match c {
Cell::OnPace => Style::new().fg(base_color),
Cell::Overspend => Style::new().fg(OVERSPEND_RGB),
Cell::Slack => Style::new().fg(Color::DarkGray),
Cell::Future => Style::new().fg(Color::DarkGray),
};
let overlay_style_for = |c: Cell| match c {
Cell::OnPace => Style::new().bg(base_color).fg(Color::Black).bold(),
Cell::Overspend => Style::new().bg(OVERSPEND_RGB).fg(Color::Black).bold(),
Cell::Slack => Style::new().bg(Color::DarkGray).fg(Color::White).bold(),
Cell::Future => Style::new().fg(Color::White).bold(),
};
let mut out: Vec<Span<'a>> = Vec::new();
let mut buf = String::new();
let mut cur_style: Option<Style> = None;
let flush = |buf: &mut String, out: &mut Vec<Span<'a>>, style: Option<Style>| {
if !buf.is_empty() {
out.push(Span::styled(std::mem::take(buf), style.unwrap_or_default()));
}
};
for i in 0..w {
let cell = cells[i];
let in_overlay = i >= overlay_start && i < overlay_end;
let (ch, style) = if in_overlay {
(overlay_chars[i - overlay_start], overlay_style_for(cell))
} else {
(base_char_for(cell), base_style_for(cell))
};
if cur_style == Some(style) {
buf.push(ch);
} else {
flush(&mut buf, &mut out, cur_style);
cur_style = Some(style);
buf.push(ch);
}
}
flush(&mut buf, &mut out, cur_style);
out
}
pub fn window_sort_key(w: &QuotaWindow) -> (u8, String) {
let wt = w.window_type.as_str();
let lower = wt.to_ascii_lowercase();
let bucket = if currency_window(wt).is_some() {
9
} else if lower.contains("credit") {
8
} else if lower.starts_with("monthly")
|| lower.contains("month")
|| lower == "total_quota"
{
3
} else if lower == "5h" || lower.starts_with("5h/") || lower.starts_with("5h ") {
1
} else if lower.starts_with("7d") || lower.starts_with("wk") || lower.starts_with("weekly") {
2
} else {
5
};
(bucket, lower)
}
pub fn bucket_label(bucket: u8) -> Option<&'static str> {
match bucket {
1 => Some("── 5h ──"),
2 => Some("── 7d ──"),
3 => Some("── monthly ──"),
8 => Some("── credits ──"),
_ => None,
}
}
pub fn display_label(window_type: &str, show_headers: bool) -> String {
let rewritten;
let window_type = if window_type == "168h" {
"7d"
} else if let Some(prefix) = window_type.strip_suffix("/168h") {
rewritten = format!("{prefix}/7d");
rewritten.as_str()
} else {
window_type
};
let renamed = match window_type {
"weekly" => "7d".to_string(),
"weekly_sonnet" => "7d Snt".to_string(),
"weekly_opus" => "7d Opus".to_string(),
"weekly_haiku" => "7d Haiku".to_string(),
"monthly_mcp" => "MCP".to_string(),
"monthly" => "month".to_string(),
"monthly_allowance" => "month".to_string(),
"total_quota" => "total".to_string(),
"extra_credits" => "Creds".to_string(),
"payg_balance" => "PAYG".to_string(),
"balance_usd" | "balance_cny" => "balance".to_string(),
"paid_cny" => "paid".to_string(),
"free_cny" => "free".to_string(),
"granted_cny" | "granted_usd" => "granted".to_string(),
"topped_up_cny" | "topped_up_usd" => "topped-up".to_string(),
"credits_usd" => "credits".to_string(),
"key_limit_usd" => "key limit".to_string(),
"spend_limit_usd" => "spend limit".to_string(),
"on_demand_usd" => "on-demand".to_string(),
"api_usage_pct" => "API%".to_string(),
"auto_usage_pct" => "Auto%".to_string(),
"billing_cycle" => "BC".to_string(),
"spend_limit" => "$ Lim".to_string(),
"bonus" => "Bonus".to_string(),
other => generic_display_label(other),
};
if !show_headers {
return renamed;
}
for prefix in [
"5h/", "wk/", "7d/", "7d ", "weekly/", "monthly/", "monthly_",
] {
if let Some(rest) = renamed.strip_prefix(prefix) {
if !rest.is_empty() {
return rest.to_string();
}
}
}
renamed
}
fn generic_display_label(window_type: &str) -> String {
let Some(rest) = window_type.strip_prefix("weekly_") else {
return window_type.to_string();
};
if rest.is_empty() {
return window_type.to_string();
}
let name = rest
.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ");
format!("7d {name}")
}
pub fn autohide_window(window_type: &str) -> bool {
matches!(window_type, "billing_cycle")
}
pub fn truncate_suffix(s: &str, n: usize) -> String {
let char_count = s.chars().count();
if char_count <= n {
return s.to_string();
}
let keep = n.saturating_sub(1);
let skip = char_count - keep;
let mut out = String::from("…");
out.extend(s.chars().skip(skip));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn to_string(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn fill_grows_from_left() {
let s = to_string(&build(0.0, None, 4, Color::Green));
assert_eq!(s, "░░░░");
let s = to_string(&build(1.0, None, 4, Color::Green));
assert_eq!(s, "████");
let s = to_string(&build(0.5, None, 4, Color::Green));
assert_eq!(s, "██░░");
}
#[test]
fn overspend_tail_shows_when_used_exceeds_time() {
let s = to_string(&build(1.0, Some(0.25), 4, Color::Green));
assert_eq!(s, "████");
}
#[test]
fn slack_shows_between_used_and_time_when_ahead() {
let s = to_string(&build(0.25, Some(0.75), 4, Color::Green));
assert_eq!(s, "█▒▒░");
}
#[test]
fn display_label_shortens_generic_weekly_model_limits() {
assert_eq!(display_label("weekly_fable", false), "7d Fable");
assert_eq!(display_label("weekly_fable", true), "Fable");
}
#[test]
fn display_label_total_quota() {
assert_eq!(display_label("total_quota", false), "total");
assert_eq!(display_label("total_quota", true), "total");
}
#[test]
fn display_label_rewrites_168h_to_7d() {
assert_eq!(display_label("168h", false), "7d");
assert_eq!(display_label("spk/168h", false), "spk/7d");
}
#[test]
fn sort_key_groups_total_quota_into_monthly_bucket() {
let monthly = QuotaWindow {
window_type: "monthly".into(),
used: 0,
limit: 0,
remaining: 0,
reset_at: None,
period_seconds: None,
};
let total = QuotaWindow {
window_type: "total_quota".into(),
used: 0,
limit: 0,
remaining: 0,
reset_at: None,
period_seconds: None,
};
let seven_d = QuotaWindow {
window_type: "weekly".into(),
used: 0,
limit: 0,
remaining: 0,
reset_at: None,
period_seconds: None,
};
assert_eq!(window_sort_key(&total).0, window_sort_key(&monthly).0);
assert!(window_sort_key(&total) > window_sort_key(&seven_d));
}
#[test]
fn sort_key_orders_5h_before_weekly() {
let a = QuotaWindow {
window_type: "wk/M*".into(),
used: 0,
limit: 0,
remaining: 0,
reset_at: None,
period_seconds: None,
};
let b = QuotaWindow {
window_type: "5h/M*".into(),
used: 0,
limit: 0,
remaining: 0,
reset_at: None,
period_seconds: None,
};
assert!(window_sort_key(&b) < window_sort_key(&a));
}
}