use crate::cache::Cache; use crate::color; use std::time::Duration; const SPARKLINE_CHARS: &[char] = &[ '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', ]; /// Baseline sparkline placeholder (space — "no data yet"). const BASELINE_CHAR: &str = " "; const BASELINE_CHAR_CH: char = ' '; /// Flatline sparkline character (lowest bar — "data exists but is flat/zero"). const FLATLINE_CHAR: char = '\u{2581}'; const FLATLINE_STR: &str = "\u{2581}"; /// Append a value to a trend file. Throttled to at most once per `interval`. /// Returns the full comma-separated series (for immediate sparkline rendering). pub fn append( cache: &Cache, key: &str, value: i64, max_points: usize, interval: Duration, ) -> Option { let trend_key = format!("trend_{key}"); // Check throttle: skip if last write was within interval if let Some(existing) = cache.get(&trend_key, interval) { return Some(existing); } // Read current series (ignoring TTL) let mut series: Vec = cache .get_stale(&trend_key) .unwrap_or_default() .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); // Skip if value unchanged from last point if series.last() == Some(&value) { // Still update the file mtime so throttle window resets let csv = series .iter() .map(|v| v.to_string()) .collect::>() .join(","); cache.set(&trend_key, &csv); return Some(csv); } series.push(value); // Trim from left to max_points if series.len() > max_points { series = series[series.len() - max_points..].to_vec(); } let csv = series .iter() .map(|v| v.to_string()) .collect::>() .join(","); cache.set(&trend_key, &csv); Some(csv) } /// Append a **delta** (difference from previous cumulative value) to a trend. /// Useful for rate-of-change sparklines (e.g., cost burn rate). /// Stores the previous cumulative value in a separate cache key for delta computation. pub fn append_delta( cache: &Cache, key: &str, cumulative_value: i64, max_points: usize, interval: Duration, ) -> Option { let trend_key = format!("trend_{key}"); let prev_key = format!("trend_{key}_prev"); // Check throttle: skip if last write was within interval if let Some(existing) = cache.get(&trend_key, interval) { return Some(existing); } // Get previous cumulative value for delta computation let prev = cache .get_stale(&prev_key) .and_then(|s| s.parse::().ok()) .unwrap_or(cumulative_value); let delta = cumulative_value - prev; // Store current cumulative for next delta cache.set(&prev_key, &cumulative_value.to_string()); // Read existing series (ignoring TTL) let mut series: Vec = cache .get_stale(&trend_key) .unwrap_or_default() .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); series.push(delta); // Trim from left to max_points if series.len() > max_points { series = series[series.len() - max_points..].to_vec(); } let csv = series .iter() .map(|v| v.to_string()) .collect::>() .join(","); cache.set(&trend_key, &csv); Some(csv) } /// Render a sparkline from comma-separated values. /// Always renders exactly `width` characters — pads with baseline chars /// on the left when fewer data points exist. pub fn sparkline(csv: &str, width: usize) -> String { let vals: Vec = csv .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if vals.is_empty() { return BASELINE_CHAR.repeat(width); } let min = *vals.iter().min().unwrap(); let max = *vals.iter().max().unwrap(); let data_count = vals.len().min(width); let pad_count = width.saturating_sub(data_count); if max == min { // Data exists but is flat — show visible lowest bars (not invisible spaces) let mut result = String::with_capacity(width * 3); for _ in 0..pad_count { result.push(BASELINE_CHAR_CH); } for _ in 0..data_count { result.push(FLATLINE_CHAR); } return result; } let range = (max - min) as f64; let mut result = String::with_capacity(width * 3); // Left-pad with baseline chars for _ in 0..pad_count { result.push(BASELINE_CHAR_CH); } // Render data points (take the last `data_count` from available values, // capped to `width`) let skip = vals.len().saturating_sub(width); for &v in vals.iter().skip(skip) { let idx = (((v - min) as f64 / range) * 7.0) as usize; result.push(SPARKLINE_CHARS[idx.min(7)]); } result } /// Render a sparkline with per-character gradient coloring. /// Each character is colored based on its normalized value (0.0=min, 1.0=max). /// Always renders exactly `width` characters — pads with DIM baseline chars on the left. /// Returns (raw, ansi) — raw is plain sparkline, ansi has gradient colors. pub fn sparkline_colored( csv: &str, width: usize, grad: &colorgrad::LinearGradient, ) -> Option<(String, String)> { let vals: Vec = csv .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if vals.is_empty() { let raw = BASELINE_CHAR.repeat(width); let ansi = format!("{}{raw}{}", color::DIM, color::RESET); return Some((raw, ansi)); } let min = *vals.iter().min().unwrap(); let max = *vals.iter().max().unwrap(); let data_count = vals.len().min(width); let pad_count = width.saturating_sub(data_count); if max == min { // Data exists but is flat — show visible lowest bars with DIM styling let mut raw = String::with_capacity(width * 3); let mut ansi = String::with_capacity(width * 20); for _ in 0..pad_count { raw.push(BASELINE_CHAR_CH); } if pad_count > 0 { ansi.push_str(color::DIM); ansi.push_str(&BASELINE_CHAR.repeat(pad_count)); ansi.push_str(color::RESET); } let flatline = FLATLINE_STR.repeat(data_count); raw.push_str(&flatline); ansi.push_str(color::DIM); ansi.push_str(&flatline); ansi.push_str(color::RESET); return Some((raw, ansi)); } let range = (max - min) as f32; let mut raw = String::with_capacity(width * 3); let mut ansi = String::with_capacity(width * 20); // Left-pad with DIM baseline chars if pad_count > 0 { let pad_str = BASELINE_CHAR.repeat(pad_count); raw.push_str(&pad_str); ansi.push_str(color::DIM); ansi.push_str(&pad_str); ansi.push_str(color::RESET); } // Render data points (take the last `width` values) let skip = vals.len().saturating_sub(width); for &v in vals.iter().skip(skip) { let norm = (v - min) as f32 / range; let idx = (norm * 7.0) as usize; let ch = SPARKLINE_CHARS[idx.min(7)]; raw.push(ch); ansi.push_str(&color::sample_fg(grad, norm)); ansi.push(ch); } ansi.push_str(color::RESET); Some((raw, ansi)) }