From af3bc6f7961f6580b5d45ded38333a32fd994ff4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 15 Sep 2026 18:44:58 +0000 Subject: [PATCH] Add stacked chart primitives for account analytics (#45763) ## What changed Add daily stacked bar rendering in the TUI analytics module, staged for dashboard integration. - Plot three categories plus an Other remainder without renormalizing values, with separate positive and negative bands for signed credits. - Add readable numeric axes, calendar ticks, a selection cursor, and a callout showing the authoritative daily total. Distinguish missing days from zero usage. - Keep the selection visible in narrow viewports and omit labels that cannot fit without truncation. - Use terminal palette colors for series and adapt secondary text to terminal color support and background. ## Testing Add unit and snapshot coverage for axis scaling, signed bars on light and dark terminals, missing and zero values, narrow layouts, tiny segments, authoritative totals, and duplicate-date remainders. GitOrigin-RevId: 2ed58def17abd14e77929016a54fd20e91d45670 --- codex-rs/tui/src/analytics.rs | 4 +- codex-rs/tui/src/analytics/plot.rs | 183 +++++++ .../tui/src/analytics/plot/annotations.rs | 183 +++++++ codex-rs/tui/src/analytics/plot/layout.rs | 55 +++ codex-rs/tui/src/analytics/plot/painting.rs | 132 +++++ codex-rs/tui/src/analytics/plot_tests.rs | 462 ++++++++++++++++++ codex-rs/tui/src/analytics/render.rs | 19 +- ...dates_render_the_full_other_remainder.snap | 41 ++ ...row_charts_keep_the_selection_visible.snap | 101 ++++ ...ong_range_charts_never_truncate_dates.snap | 98 ++++ ..._axes_are_hidden_instead_of_truncated.snap | 107 ++++ ...idpoint_and_descend_from_the_baseline.snap | 47 ++ ...t_keeps_the_authoritative_daily_total.snap | 29 ++ ...ytics__plot__tests__signed_chart_dark.snap | 41 ++ ...tics__plot__tests__signed_chart_light.snap | 41 ++ ...their_series_color_beside_a_large_day.snap | 66 +++ ..._credit_history_uses_a_readable_scale.snap | 27 + codex-rs/tui/src/analytics/styles.rs | 37 ++ 18 files changed, 1671 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/analytics/plot.rs create mode 100644 codex-rs/tui/src/analytics/plot/annotations.rs create mode 100644 codex-rs/tui/src/analytics/plot/layout.rs create mode 100644 codex-rs/tui/src/analytics/plot/painting.rs create mode 100644 codex-rs/tui/src/analytics/plot_tests.rs create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap create mode 100644 codex-rs/tui/src/analytics/styles.rs diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index f6dd08c536..5c29a6fad8 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -1,11 +1,13 @@ -//! Account analytics data preparation, staged for the dashboard integration. +//! Account analytics data and chart primitives, staged for the dashboard integration. mod client; mod data; mod models; mod normalize; +mod plot; mod render; mod report_data; +mod styles; mod tokens; #[cfg(test)] diff --git a/codex-rs/tui/src/analytics/plot.rs b/codex-rs/tui/src/analytics/plot.rs new file mode 100644 index 0000000000..bbbaa80bc6 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot.rs @@ -0,0 +1,183 @@ +//! Daily stacked bars with fixed calendar ticks, a selection cursor, and an anchored value. +//! Relative usage retains the server's scale; signed credits keep both bands. + +mod annotations; +mod layout; +mod painting; + +use self::layout::Layout; +use self::painting::Band; +use super::data; +use super::render::parts; +use crate::analytics::models::AccountAnalyticsDay; +use crate::analytics::models::AccountAnalyticsUnit; +use crate::analytics::models::AccountAnalyticsValue; +use chrono::NaiveDate; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; + +/// Share the chart's scale with the selected-day summary, including signed values. +pub(super) fn peak( + days: &[(NaiveDate, Option<&AccountAnalyticsDay>)], + categories: &[AccountAnalyticsValue], +) -> f64 { + days.iter() + .filter_map(|(_, day)| *day) + .map(|day| { + parts(&day.values, categories) + .iter() + .map(|value| value.abs()) + .sum::() + .max(day.total.abs()) + }) + .fold(/*init*/ 0.0, f64::max) +} + +/// Two readable intervals cover the range; counts never use fractional ticks. +pub(super) fn axis_max(peak: f64, unit: AccountAnalyticsUnit) -> f64 { + if peak == 0.0 { + return if matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) { + 2.0 + } else { + 1.0 + }; + } + let step = if matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) { + (peak / 2.0).max(/*other*/ 1.0) + } else { + peak / 2.0 + }; + let magnitude = 10.0_f64.powf(step.log10().floor()); + let normalized = step / magnitude; + let multiple = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0] + .into_iter() + .find(|multiple| { + *multiple >= normalized + && (!matches!( + unit, + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens + ) || (multiple * magnitude).fract() == 0.0) + }) + .unwrap_or(/*default*/ 10.0); + let ceiling = multiple * magnitude * 2.0; + if ceiling.is_finite() && ceiling >= peak { + ceiling + } else { + peak + } +} + +/// Rendered chart geometry exposes its positive and optional negative value bands. +#[derive(Default)] +pub(super) struct Chart { + pub(super) lines: Vec>, + pub(super) bands: usize, +} + +/// Render a calendar selection; the cursor must index days and dimensions fit the terminal. +pub(super) fn chart( + days: &[(NaiveDate, Option<&AccountAnalyticsDay>)], + categories: &[AccountAnalyticsValue], + cursor: usize, + width: usize, + height: usize, + unit: AccountAnalyticsUnit, +) -> Chart { + if days.is_empty() || width == 0 { + return Chart::default(); + } + let values = days + .iter() + .map(|(_, day)| { + day.map_or( + /*default*/ [0.0; 4], + |day| parts(&day.values, categories), + ) + }) + .collect::>(); + let bands = if values.iter().flatten().any(|value| *value < 0.0) { + &[Band::Positive, Band::Negative][..] + } else { + &[Band::Positive][..] + }; + let peak = axis_max(peak(days, categories), unit); + let label_width = + tick(peak, unit).width().max(tick(peak / 2.0, unit).width()) + bands.len() - 1 + 2; + let layout = Layout::new(days.len(), cursor, width, height, label_width, bands); + let renderer = Renderer { + days, + values, + cursor, + peak, + unit, + bands, + layout, + }; + let mut buf = Buffer::empty(Rect::new( + /*x*/ 0, + /*y*/ 0, + width as u16, + (layout.marker_row + 2) as u16, + )); + renderer.paint_bars(&mut buf); + renderer.annotate_missing(&mut buf); + renderer.paint_axis(&mut buf); + renderer.annotate_selection(&mut buf); + renderer.annotate_calendar(&mut buf); + let lines = buf + .content + .chunks(width) + .map(|row| { + Line::from( + row.iter() + .map(|cell| Span::styled(cell.symbol().to_owned(), cell.style())) + .collect::>(), + ) + }) + .collect(); + Chart { + lines, + bands: bands.len(), + } +} + +/// Shared inputs for painting and annotations; all phases use the same calendar geometry. +struct Renderer<'a> { + days: &'a [(NaiveDate, Option<&'a AccountAnalyticsDay>)], + values: Vec<[f64; 4]>, + cursor: usize, + peak: f64, + unit: AccountAnalyticsUnit, + bands: &'static [Band], + layout: Layout, +} + +fn amount(value: f64, unit: AccountAnalyticsUnit) -> String { + match unit { + AccountAnalyticsUnit::RelativeUsage => data::amount(value), + AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens => data::amount(value.round()), + AccountAnalyticsUnit::Credits => data::credit_amount(value), + } +} + +fn tick(value: f64, unit: AccountAnalyticsUnit) -> String { + match unit { + AccountAnalyticsUnit::Credits => data::amount(value), + AccountAnalyticsUnit::RelativeUsage + | AccountAnalyticsUnit::Count + | AccountAnalyticsUnit::Tokens => amount(value, unit), + } +} + +#[cfg(test)] +#[path = "plot_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/plot/annotations.rs b/codex-rs/tui/src/analytics/plot/annotations.rs new file mode 100644 index 0000000000..1666d66542 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/annotations.rs @@ -0,0 +1,183 @@ +//! Missing-day markers, selected-value callouts, and stable calendar labels. + +use super::Renderer; +use super::amount; +use super::layout::Layout; +use super::painting::Band; +use crate::analytics::styles::number; +use crate::analytics::styles::secondary_style; +use crate::style::accent_style; +use ratatui::buffer::Buffer; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use unicode_width::UnicodeWidthStr; + +impl Renderer<'_> { + pub(super) fn annotate_missing(&self, buf: &mut Buffer) { + let Layout { + count, + height, + start, + .. + } = self.layout; + for (index, parts) in self.values[start..start + count].iter().enumerate() { + // The selected zero/missing value gets its own callout below; avoid repeating it. + if start + index != self.cursor { + let marker = match self.days[start + index].1 { + None => Some('—'), + Some(day) if day.total == 0.0 && parts.iter().all(|value| *value == 0.0) => { + Some('0') + } + Some(_) => None, + }; + if let Some(marker) = marker { + buf[(self.layout.center(index) as u16, height as u16)] + .set_char(marker) + .set_style(Style::default().dim()); + } + } + } + } + + pub(super) fn annotate_selection(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + height, + marker_row, + plot_width, + start, + width, + .. + } = self.layout; + let selected_total = self.days[self.cursor] + .1 + .map_or(/*default*/ 0.0, |day| day.total); + let selected = self.days[self.cursor] + .1 + .map_or_else(|| "—".into(), |_| amount(selected_total, self.unit)); + // A partial number can mean a different amount; leave the cursor when the value cannot fit. + let selected = if selected.width() <= plot_width { + selected + } else { + String::new() + }; + let x = self.layout.center(self.cursor - start); + let label_width = selected.width(); + let label_x = x + .saturating_sub(label_width / 2) + .clamp(axis_width, width - label_width); + let below = selected_total < 0.0; + let band = if below { + Band::Negative + } else { + Band::Positive + }; + let extent = self.values[self.cursor] + .iter() + .map(|value| band.amount(*value)) + .sum::(); + let rows = ((extent * height as f64 / self.peak).ceil() as usize).min(height); + let cap = if below { + baseline + rows + } else { + baseline - rows + }; + let mut label_y = if below { + cap + 1 + } else { + cap.saturating_sub(/*rhs*/ 1) + }; + // Keep the callout close without painting over a neighboring bar. A guide connects any gap. + while (label_x..label_x + label_width) + .any(|col| buf[(col as u16, label_y as u16)].symbol() != " ") + { + if below { + label_y += 1; + } else if label_y > 0 { + label_y -= 1; + } else { + break; + } + } + let guide = if below { + cap + 1..label_y + } else { + label_y + 1..cap + }; + for y in guide { + buf[(x as u16, y as u16)] + .set_char('┊') + .set_style(accent_style()); + } + buf.set_line( + label_x as u16, + label_y as u16, + &Line::from(number(selected)), + label_width as u16, + ); + buf[(x as u16, marker_row as u16)] + .set_char('▲') + .set_style(accent_style()); + } + + pub(super) fn annotate_calendar(&self, buf: &mut Buffer) { + let Layout { + axis_width, + count, + marker_row, + plot_width, + start, + width, + .. + } = self.layout; + // Calendar ticks depend only on the range/width, never on the current selection. + let mut occupied = vec![false; width]; + // Give the two range endpoints priority over intermediate weekly ticks. + for index in std::iter::once(/*value*/ 0).chain((1..count).rev()) { + if self.days.len() > 7 + && !(start + index).is_multiple_of(/*rhs*/ 7) + && index + 1 != count + { + continue; + } + let format = if self.days.len() <= 7 && plot_width / count < 7 { + "%-d" + } else { + "%b %-d" + }; + let mut label = self.days[start + index].0.format(format).to_string(); + if label.width() > plot_width { + label = self.days[start + index].0.format("%-d").to_string(); + } + if label.width() > plot_width { + continue; + } + let offset = self + .layout + .center(index) + .saturating_sub(label.len() / 2) + .clamp(axis_width, width - label.len()); + if occupied[offset.saturating_sub(/*rhs*/ 1)..(offset + label.len() + 1).min(width)] + .iter() + .any(|used| *used) + { + continue; + } + occupied[offset..offset + label.len()].fill(/*value*/ true); + let label = if start + index == self.cursor { + label.set_style(accent_style()).underlined() + } else { + label.set_style(secondary_style()) + }; + buf.set_line( + offset as u16, + (marker_row + 1) as u16, + &Line::from(label), + (width - offset) as u16, + ); + } + } +} diff --git a/codex-rs/tui/src/analytics/plot/layout.rs b/codex-rs/tui/src/analytics/plot/layout.rs new file mode 100644 index 0000000000..e3d8a9e2d6 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/layout.rs @@ -0,0 +1,55 @@ +//! Calendar viewport and shared coordinates for bars, axes, and annotations. + +use super::painting::Band; + +#[derive(Clone, Copy)] +pub(super) struct Layout { + pub(super) width: usize, + pub(super) height: usize, + pub(super) axis_width: usize, + pub(super) plot_width: usize, + pub(super) count: usize, + pub(super) start: usize, + pub(super) baseline: usize, + pub(super) marker_row: usize, +} + +impl Layout { + pub(super) fn new( + day_count: usize, + cursor: usize, + width: usize, + height: usize, + label_width: usize, + bands: &[Band], + ) -> Self { + // Hide the axis when a complete label would crowd out the bars. + let axis_width = if width >= 30 && label_width <= width / 3 { + label_width + } else { + 0 + }; + let plot_width = width - axis_width; + let count = day_count.min(plot_width); + let start = cursor.saturating_sub(count - 1).min(day_count - count); + let baseline = height + 1; + let marker_row = baseline + if bands.len() == 2 { height + 2 } else { 1 }; + Self { + width, + height, + axis_width, + plot_width, + count, + start, + baseline, + marker_row, + } + } + + pub(super) fn center(self, index: usize) -> usize { + self.axis_width + + (index * self.plot_width / self.count + (index + 1) * self.plot_width / self.count + - 1) + / 2 + } +} diff --git a/codex-rs/tui/src/analytics/plot/painting.rs b/codex-rs/tui/src/analytics/plot/painting.rs new file mode 100644 index 0000000000..358bad4d0a --- /dev/null +++ b/codex-rs/tui/src/analytics/plot/painting.rs @@ -0,0 +1,132 @@ +//! Paint signed bar cells and numeric axes using the shared layout and scale. + +use super::Renderer; +use super::layout::Layout; +use super::tick; +use crate::analytics::styles::secondary_style; +use crate::analytics::styles::series_colors; +use ratatui::buffer::Buffer; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::text::Line; + +/// A signed stack grows away from the baseline; amounts are nonnegative within each band. +#[derive(Clone, Copy)] +pub(super) enum Band { + Positive, + Negative, +} + +impl Band { + pub(super) fn amount(self, value: f64) -> f64 { + match self { + Self::Positive => value, + Self::Negative => -value, + } + .max(/*other*/ 0.0) + } + + fn row_y(self, baseline: usize, row: usize) -> usize { + match self { + Self::Positive => baseline - row - 1, + Self::Negative => baseline + row + 1, + } + } + + fn partial_glyph(self, level: usize) -> char { + // Reversing the complementary lower block paints a top-anchored partial refund. + let index = match self { + Self::Negative if level < 8 => 8 - level, + Self::Positive | Self::Negative => level, + }; + [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][index] + } +} + +impl Renderer<'_> { + pub(super) fn paint_bars(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + count, + height, + plot_width, + start, + .. + } = self.layout; + let colors = series_colors(); + for (index, parts) in self.values[start..start + count].iter().enumerate() { + let left = axis_width + index * plot_width / count; + let step = (index + 1) * plot_width / count - index * plot_width / count; + let bar_width = (step * 2 / 3).clamp(/*min*/ 1, /*max*/ 12); + let x = left + (step - bar_width) / 2; + for band in self.bands { + let plotted = parts.iter().map(|value| band.amount(*value)).sum::(); + let scaled = ((plotted * (height * 8) as f64 / self.peak) as usize) + .max(usize::from(plotted > 0.0)); + for row in 0..height { + let level = scaled.saturating_sub(row * 8).min(/*other*/ 8); + if level == 0 { + continue; + } + let reverse = matches!(band, Band::Negative) && level < 8; + let glyph = band.partial_glyph(level); + let bottom = (row * 8) as f64 * self.peak / (height * 8) as f64; + let top = + ((row * 8 + level) as f64 * self.peak / (height * 8) as f64).min(plotted); + let sample = bottom + (top - bottom) / 2.0; + let mut cumulative = 0.0; + let color = parts + .iter() + .position(|value| { + cumulative += band.amount(*value); + sample < cumulative + }) + .unwrap_or(/*default*/ 3); + let y = band.row_y(baseline, row); + for col in x..x + bar_width { + let cell = &mut buf[(col as u16, y as u16)]; + cell.set_char(glyph).set_fg(colors[color]); + if reverse { + cell.set_style(Style::default().reversed()); + } + } + } + } + } + } + + pub(super) fn paint_axis(&self, buf: &mut Buffer) { + let Layout { + axis_width, + baseline, + height, + width, + .. + } = self.layout; + for x in axis_width..width { + buf[(x as u16, baseline as u16)] + .set_char('─') + .set_style(Style::default().dim()); + } + if axis_width > 0 { + let mut ticks = vec![(1, self.peak), (baseline, 0.0)]; + if height >= 4 { + ticks.push((1 + height / 2, self.peak / 2.0)); + } + if self.bands.len() == 2 { + ticks.push((baseline + height, -self.peak)); + } + for (y, value) in ticks { + let label = tick(value, self.unit); + let x = axis_width.saturating_sub(label.len() + 1); + buf.set_line( + x as u16, + y as u16, + &Line::from(label.set_style(secondary_style())), + axis_width as u16, + ); + } + } + } +} diff --git a/codex-rs/tui/src/analytics/plot_tests.rs b/codex-rs/tui/src/analytics/plot_tests.rs new file mode 100644 index 0000000000..935eeea7b3 --- /dev/null +++ b/codex-rs/tui/src/analytics/plot_tests.rs @@ -0,0 +1,462 @@ +//! Chart scales, signed bands, missing days, narrow viewports, and terminal palettes. + +use super::*; +use crate::analytics::models::AccountAnalyticsHistory; +use crate::analytics::render::categories; +use crate::analytics::styles::series_colors; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; + +fn value(key: &str, value: f64) -> AccountAnalyticsValue { + AccountAnalyticsValue { + key: key.into(), + label: key.into(), + value, + } +} + +fn display(chart: &Chart) -> String { + let text = chart + .lines + .iter() + .map(|line| line.to_string().trim_end().to_owned()) + .collect::>() + .join("\n"); + let mut palette = Vec::new(); + let mut rows = Vec::new(); + for (row, line) in chart.lines.iter().enumerate() { + let mut previous = None; + let mut runs = Vec::new(); + let mut column = 0; + for span in &line.spans { + let style = format!("{:?}", span.style); + let index = palette + .iter() + .position(|candidate| *candidate == style) + .unwrap_or_else(|| { + palette.push(style); + palette.len() - 1 + }); + if previous != Some(index) { + runs.push(format!("{column}:{index}")); + previous = Some(index); + } + column += span.width(); + } + rows.push(format!("{row}: {}", runs.join(" "))); + } + let palette = palette + .iter() + .enumerate() + .map(|(index, style)| format!("{index}: {style}")) + .collect::>() + .join("\n"); + format!( + "{text}\n\nPalette:\n{palette}\nRows (column:palette):\n{}", + rows.join("\n") + ) +} + +#[test] +fn numeric_axes_use_readable_intervals() { + use AccountAnalyticsUnit::Count; + use AccountAnalyticsUnit::Credits; + use AccountAnalyticsUnit::RelativeUsage; + use AccountAnalyticsUnit::Tokens; + let cases = [ + (82.0, Count, 100.0), + (4_400.0, Credits, 5_000.0), + (4_999.0, Credits, 5_000.0), + (5_001.0, Credits, 6_000.0), + (49.0, Count, 50.0), + (1.0, Count, 2.0), + (3.0, Count, 4.0), + (1_337.0, Credits, 1_500.0), + (0.07, Credits, 0.08), + (0.000004, Credits, 0.000004), + (82.0, RelativeUsage, 100.0), + (1.0, Tokens, 2.0), + ]; + assert_eq!( + cases.map(|(peak, unit, _)| axis_max(peak, unit)), + cases.map(|(_, _, expected)| expected) + ); +} + +#[test] +fn stacked_parts_keep_the_remainder_and_full_denominator() { + let values = vec![ + value("a", /*value*/ 4.0), + value("b", /*value*/ 2.0), + value("c", /*value*/ 1.0), + value("d", /*value*/ 6.0), + value("e", /*value*/ -1.0), + ]; + let categories = vec![values[1].clone(), values[0].clone(), values[2].clone()]; + assert_eq!(parts(&values, &categories), [2.0, 4.0, 1.0, 5.0]); + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 20.0, + values, + }; + let date = day.date; + assert_eq!(peak(&[(date, Some(&day))], &categories), 20.0); + let refund = AccountAnalyticsDay { + total: -1.0, + values: vec![value("a", /*value*/ 4.0), value("b", /*value*/ -5.0)], + ..day + }; + assert_eq!(peak(&[(date, Some(&refund))], &categories), 9.0); +} + +#[test] +fn signed_chart_on_light_terminal() { + signed_chart( + "light", + DefaultColors { + fg: (0, 0, 0), + bg: (255, 255, 255), + }, + ); +} + +#[test] +fn signed_chart_on_dark_terminal() { + signed_chart( + "dark", + DefaultColors { + fg: (230, 230, 230), + bg: (16, 16, 16), + }, + ); +} + +fn signed_chart(theme: &str, colors: DefaultColors) { + let history = AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: None, + data: vec![ + AccountAnalyticsDay { + date: "2026-01-13".parse().unwrap(), + total: 8.0, + values: vec![value("a", /*value*/ 10.0), value("b", /*value*/ -2.0)], + }, + AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: -3.0, + values: vec![value("a", /*value*/ 1.0), value("b", /*value*/ -4.0)], + }, + AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 0.0, + values: vec![value("a", /*value*/ 3.0), value("b", /*value*/ -3.0)], + }, + ], + }; + let days = history + .data + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let categories = categories(&history); + with_test_default_colors(colors, || { + let rendered = chart( + &days, + &categories, + /*cursor*/ 1, + /*width*/ 44, + /*height*/ 4, + history.unit, + ); + assert_eq!(rendered.bands, 2); + insta::assert_snapshot!(format!("signed_chart_{theme}"), display(&rendered)); + }); +} + +#[test] +fn missing_zero_and_narrow_charts_keep_the_selection_visible() { + let zero = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 0.0, + values: vec![value("a", /*value*/ 0.0)], + }; + let used = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 5.0, + values: vec![value("a", /*value*/ 5.0)], + }; + let days = [ + ("2026-01-13".parse().unwrap(), None), + (zero.date, Some(&zero)), + (used.date, Some(&used)), + ]; + with_test_default_colors( + DefaultColors { + fg: (230, 230, 230), + bg: (16, 16, 16), + }, + || { + let mut snapshots = Vec::new(); + for (width, cursor) in [(36, 0), (36, 1), (8, 2), (1, 2)] { + let rendered = chart( + &days, + &used.values, + cursor, + width, + /*height*/ 3, + AccountAnalyticsUnit::Count, + ); + assert_eq!(rendered.bands, 1); + assert!(rendered.lines.iter().all(|line| line.width() <= width)); + assert_eq!( + rendered + .lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| span.content == "▲") + .count(), + 1 + ); + if cursor == 0 { + let dash = rendered + .lines + .iter() + .find_map(|line| line.to_string().find('—')); + let cursor = rendered + .lines + .iter() + .find_map(|line| line.to_string().find('▲')); + assert_eq!(dash, cursor); + } + snapshots.push(format!( + "width={width}, cursor={cursor}\n{}", + display(&rendered) + )); + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }, + ); +} + +#[test] +fn zero_credit_history_uses_a_readable_scale() { + let history = crate::analytics::normalize::history( + serde_json::from_str(r#"{"data":[]}"#).unwrap(), + crate::analytics::models::AccountAnalyticsReport::Credits, + crate::analytics::models::AccountAnalyticsGrouping::Surface, + "2026-01-09".parse().unwrap(), + "2026-01-15".parse().unwrap(), + ) + .unwrap() + .unwrap(); + let days = history + .data + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let rendered = chart( + &days, + &[], + /*cursor*/ 6, + /*width*/ 44, + /*height*/ 4, + history.unit, + ); + assert_eq!(axis_max(peak(&days, &[]), history.unit), 1.0); + insta::assert_snapshot!(display(&rendered)); +} + +#[test] +fn partial_cells_use_the_midpoint_and_descend_from_the_baseline() { + let categories = vec![value("a", /*value*/ 0.8), value("b", /*value*/ 0.95)]; + let mut snapshots = Vec::new(); + for sign in [1.0, -1.0] { + let day = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 1.75 * sign, + values: categories + .iter() + .map(|value| AccountAnalyticsValue { + value: value.value * sign, + ..value.clone() + }) + .collect(), + }; + let days = [ + (day.date, Some(&day)), + ("2026-01-15".parse().unwrap(), None), + ]; + let rendered = chart( + &days, + &categories, + /*cursor*/ 1, + /*width*/ 12, + /*height*/ 1, + AccountAnalyticsUnit::Credits, + ); + let cell = &rendered.lines[if sign > 0.0 { 1 } else { 3 }].spans[1]; + assert_eq!(cell.style.fg, Some(series_colors()[1])); + assert_eq!(cell.content.as_ref(), if sign > 0.0 { "▇" } else { "▁" }); + assert_eq!( + cell.style + .add_modifier + .contains(ratatui::style::Modifier::REVERSED), + sign < 0.0 + ); + snapshots.push(format!("sign={sign}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn oversized_axes_are_hidden_instead_of_truncated() { + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 100_000_000.0, + values: vec![value("a", /*value*/ 100_000_000.0)], + }; + let days = [(day.date, Some(&day))]; + let mut snapshots = Vec::new(); + for width in [8, 30, 35, 44] { + let rendered = chart( + &days, + &day.values, + /*cursor*/ 0, + width, + /*height*/ 4, + AccountAnalyticsUnit::Tokens, + ); + let baseline = rendered.lines[5].to_string(); + assert_eq!(baseline.starts_with('─'), width < 36); + if width == 8 { + assert!(rendered.lines[0].to_string().trim().is_empty()); + } + snapshots.push(format!("width={width}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn selected_callout_keeps_the_authoritative_daily_total() { + let day = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 20.0, + values: vec![value("a", /*value*/ 8.0), value("b", /*value*/ 4.0)], + }; + let days = [(day.date, Some(&day))]; + let rendered = chart( + &days, + &day.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 4, + AccountAnalyticsUnit::Credits, + ); + assert!(display(&rendered).contains("20.00")); + assert!(!display(&rendered).contains("12.00")); + insta::assert_snapshot!(display(&rendered)); +} + +#[test] +fn narrow_long_range_charts_never_truncate_dates() { + let start: NaiveDate = "2026-01-01".parse().unwrap(); + let history = start + .iter_days() + .take(/*n*/ 30) + .map(|date| AccountAnalyticsDay { + date, + total: 1.0, + values: vec![value("a", /*value*/ 1.0)], + }) + .collect::>(); + let days = history + .iter() + .map(|day| (day.date, Some(day))) + .collect::>(); + let mut snapshots = Vec::new(); + for (width, expected) in [(1, ""), (2, "15"), (5, "15"), (6, "Jan 15")] { + let rendered = chart( + &days, + &history[0].values, + /*cursor*/ 14, + width, + /*height*/ 3, + AccountAnalyticsUnit::Count, + ); + assert_eq!(rendered.lines.last().unwrap().to_string().trim(), expected); + snapshots.push(format!("width={width}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn tiny_segments_keep_their_series_color_beside_a_large_day() { + let large = AccountAnalyticsDay { + date: "2026-01-14".parse().unwrap(), + total: 100.0, + values: vec![value("a", /*value*/ 100.0)], + }; + let mut snapshots = Vec::new(); + for sign in [1.0, -1.0] { + let tiny = AccountAnalyticsDay { + date: "2026-01-15".parse().unwrap(), + total: 0.01 * sign, + values: vec![value("a", 0.01 * sign)], + }; + let days = [(large.date, Some(&large)), (tiny.date, Some(&tiny))]; + let rendered = chart( + &days, + &large.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 4, + AccountAnalyticsUnit::Credits, + ); + let glyph = if sign > 0.0 { "▁" } else { "▇" }; + let cells = rendered + .lines + .iter() + .flat_map(|line| &line.spans) + .filter(|span| span.content == glyph) + .collect::>(); + assert!(!cells.is_empty()); + assert!( + cells + .iter() + .all(|cell| cell.style.fg == Some(series_colors()[0])) + ); + snapshots.push(format!("sign={sign}\n{}", display(&rendered))); + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn duplicate_message_dates_render_the_full_other_remainder() { + use crate::analytics::models::AccountAnalyticsGrouping; + use crate::analytics::models::AccountAnalyticsReport; + let date = "2026-01-15".parse().unwrap(); + let response = serde_json::from_value(serde_json::json!({"data": [ + {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}, + {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]} + ]})).unwrap(); + let history = crate::analytics::normalize::history( + response, + AccountAnalyticsReport::Messages, + AccountAnalyticsGrouping::Surface, + date, + date, + ) + .unwrap() + .unwrap(); + let day = &history.data[0]; + let rendered = chart( + &[(date, Some(day))], + &day.values, + /*cursor*/ 0, + /*width*/ 44, + /*height*/ 10, + AccountAnalyticsUnit::Count, + ); + insta::assert_snapshot!(display(&rendered)); +} diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs index 998a181570..0229a604fd 100644 --- a/codex-rs/tui/src/analytics/render.rs +++ b/codex-rs/tui/src/analytics/render.rs @@ -1,4 +1,4 @@ -//! Shared report category aggregation for token model filtering. +//! Shared report category aggregation and stacked-chart partitioning. use super::models::AccountAnalyticsHistory; use super::models::AccountAnalyticsValue; @@ -24,3 +24,20 @@ pub(super) fn categories(history: &AccountAnalyticsHistory) -> Vec [f64; 4] { + let mut parts = [0.0; 4]; + for value in values { + let index = categories + .iter() + .take(/*n*/ 3) + .position(|category| category.key == value.key) + .unwrap_or(/*default*/ 3); + parts[index] += value.value; + } + parts +} diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap new file mode 100644 index 0000000000..423a0c2ad6 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap @@ -0,0 +1,41 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + 20 + 20 ████████████ + ████████████ + ████████████ + ████████████ + ████████████ + 10 ████████████ + ████████████ + ████████████ + ████████████ + ████████████ + 0 ──────────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 22:1 24:0 +1: 0:0 1:2 3:0 18:3 30:0 +2: 0:0 18:3 30:0 +3: 0:0 18:4 30:0 +4: 0:0 18:4 30:0 +5: 0:0 18:4 30:0 +6: 0:0 1:2 3:0 18:4 30:0 +7: 0:0 18:4 30:0 +8: 0:0 18:4 30:0 +9: 0:0 18:4 30:0 +10: 0:0 18:4 30:0 +11: 0:0 2:2 3:0 4:2 +12: 0:0 23:1 24:0 +13: 0:0 20:5 26:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap new file mode 100644 index 0000000000..2d3bb8bf83 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap @@ -0,0 +1,101 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=36, cursor=0 + + 6 ▄▄▄▄▄▄▄ + ███████ + — 0 ███████ + 0 ───────────────────────────────── + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 2:0 27:2 34:0 +2: 0:0 27:2 34:0 +3: 0:0 8:3 9:0 19:4 20:0 27:2 34:0 +4: 0:0 1:1 2:0 3:4 +5: 0:0 8:3 9:0 +6: 0:0 5:5 11:0 16:1 22:0 27:1 33:0 + +width=36, cursor=1 + + 6 ▄▄▄▄▄▄▄ + ███████ + — 0 ███████ + 0 ───────────────────────────────── + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 2:0 27:2 34:0 +2: 0:0 27:2 34:0 +3: 0:0 8:3 9:0 19:4 20:0 27:2 34:0 +4: 0:0 1:1 2:0 3:3 +5: 0:0 19:4 20:0 +6: 0:0 5:1 11:0 16:5 22:0 27:1 33:0 + +width=8, cursor=2 + 5 + ▄▄ + ██ +— 0 ██ +──────── + ▲ +13 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 6:1 7:0 +1: 0:0 5:2 7:0 +2: 0:0 5:2 7:0 +3: 0:3 1:0 3:3 4:0 5:2 7:0 +4: 0:3 +5: 0:0 6:1 7:0 +6: 0:4 2:0 5:5 7:0 + +width=1, cursor=2 +5 +▄ +█ +█ +─ +▲ + + +Palette: +0: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +Rows (column:palette): +0: 0:0 +1: 0:1 +2: 0:1 +3: 0:1 +4: 0:2 +5: 0:0 +6: 0:3 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap new file mode 100644 index 0000000000..1448b75fff --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap @@ -0,0 +1,98 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=1 + +1 +▄ +█ +─ +▲ + + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +Rows (column:palette): +0: 0:0 +1: 0:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:1 +6: 0:0 + +width=2 + + 1 +▄▄ +██ +── + ▲ +15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 1:1 +6: 0:4 + +width=5 + + 1 +▄▄▄▄▄ +█████ +───── + ▲ + 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 4:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 4:1 +6: 0:0 3:4 + +width=6 + + 1 +▄▄▄▄▄▄ +██████ +────── + ▲ +Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 5:1 +2: 0:2 +3: 0:2 +4: 0:3 +5: 0:0 5:1 +6: 0:4 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap new file mode 100644 index 0000000000..d77e9815bf --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap @@ -0,0 +1,107 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +width=8 + + █████ + █████ + █████ + █████ +──────── + ▲ +Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 6:0 +2: 0:0 1:1 6:0 +3: 0:0 1:1 6:0 +4: 0:0 1:1 6:0 +5: 0:2 +6: 0:0 3:3 4:0 +7: 0:4 6:0 + +width=30 + 100,000,000 + ████████████ + ████████████ + ████████████ + ████████████ +────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 9:1 20:0 +1: 0:0 9:2 21:0 +2: 0:0 9:2 21:0 +3: 0:0 9:2 21:0 +4: 0:0 9:2 21:0 +5: 0:3 +6: 0:0 14:1 15:0 +7: 0:0 11:4 17:0 + +width=35 + 100,000,000 + ████████████ + ████████████ + ████████████ + ████████████ +─────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 12:1 23:0 +1: 0:0 11:2 23:0 +2: 0:0 11:2 23:0 +3: 0:0 11:2 23:0 +4: 0:0 11:2 23:0 +5: 0:3 +6: 0:0 17:1 18:0 +7: 0:0 14:4 20:0 + +width=44 + 100,000,000 + 100,000,000 ████████████ + ████████████ + 50,000,000 ████████████ + ████████████ + 0 ─────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 23:1 34:0 +1: 0:0 1:2 12:0 22:3 34:0 +2: 0:0 22:3 34:0 +3: 0:0 2:2 12:0 22:3 34:0 +4: 0:0 22:3 34:0 +5: 0:0 11:2 12:0 13:2 +6: 0:0 28:1 29:0 +7: 0:0 25:4 31:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap new file mode 100644 index 0000000000..5cce260999 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__partial_cells_use_the_midpoint_and_descend_from_the_baseline.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +sign=1 + + ▇▇▇▇ — +──────────── + ▲ + 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 5:0 8:2 9:0 +2: 0:3 +3: 0:0 8:2 9:0 +4: 0:0 1:3 3:0 7:4 9:0 + +sign=-1 + + — +──────────── + ▁▁▁▁ + + ▲ + 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 8:1 9:0 +2: 0:2 +3: 0:0 1:3 5:0 +4: 0:0 +5: 0:0 8:1 9:0 +6: 0:0 1:2 3:0 7:4 9:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap new file mode 100644 index 0000000000..64bc60cf31 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__selected_callout_keeps_the_authoritative_daily_total.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 20 20.00 + ▃▃▃▃▃▃▃▃▃▃▃▃ + 10 ████████████ + ████████████ + 0 ──────────────────────────────────────── + ▲ + Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 1:1 3:0 21:2 26:0 +2: 0:0 18:3 30:0 +3: 0:0 1:1 3:0 18:4 30:0 +4: 0:0 18:4 30:0 +5: 0:0 2:1 3:0 4:1 +6: 0:0 23:2 24:0 +7: 0:0 20:5 26:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap new file mode 100644 index 0000000000..569de4714f --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_dark.snap @@ -0,0 +1,41 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 15 + ▅▅▅▅▅▅▅▅ + 7.5 ████████ + ████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆ + 0 ────────────────────────────────────── + ▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂ + + -3.00 + -15 + + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +7: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 5:0 +2: 0:0 8:2 16:0 +3: 0:0 2:1 5:0 8:2 16:0 +4: 0:0 8:2 16:0 20:2 28:0 33:2 41:0 +5: 0:0 4:1 5:0 6:3 +6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0 +7: 0:0 +8: 0:0 22:6 27:0 +9: 0:0 2:1 5:0 +10: 0:0 +11: 0:0 24:6 25:0 +12: 0:0 8:1 14:0 21:7 27:0 34:1 40:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap new file mode 100644 index 0000000000..67c34343e7 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__signed_chart_light.snap @@ -0,0 +1,41 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 15 + ▅▅▅▅▅▅▅▅ + 7.5 ████████ + ████████ ▂▂▂▂▂▂▂▂ ▆▆▆▆▆▆▆▆ + 0 ────────────────────────────────────── + ▄▄▄▄▄▄▄▄ ████████ ▂▂▂▂▂▂▂▂ + + -3.00 + -15 + + ▲ + Jan 13 Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Rgb(76, 76, 76)).bg(Color::Reset).underline_color(Color::Reset) +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +4: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset) +6: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold() +7: Style::new().fg(Color::Rgb(0, 95, 135)).bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 5:0 +2: 0:0 8:2 16:0 +3: 0:0 2:1 5:0 8:2 16:0 +4: 0:0 8:2 16:0 20:2 28:0 33:2 41:0 +5: 0:0 4:1 5:0 6:3 +6: 0:0 8:4 16:0 20:5 28:0 33:4 41:0 +7: 0:0 +8: 0:0 22:6 27:0 +9: 0:0 2:1 5:0 +10: 0:0 +11: 0:0 24:6 25:0 +12: 0:0 8:1 14:0 21:7 27:0 34:1 40:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap new file mode 100644 index 0000000000..1d80f5587b --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__tiny_segments_keep_their_series_color_beside_a_large_day.snap @@ -0,0 +1,66 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +sign=1 + 100.00 + 100 ████████████ + ████████████ + 50 ████████████ + ████████████ ▁▁▁▁▁▁▁▁▁▁▁▁ + 0 ─────────────────────────────────────── + ▲ + Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 11:1 17:0 +1: 0:0 1:2 4:0 8:3 20:0 +2: 0:0 8:3 20:0 +3: 0:0 2:2 4:0 8:3 20:0 +4: 0:0 8:3 20:0 28:3 40:0 +5: 0:0 3:2 4:0 5:2 +6: 0:0 14:1 15:0 +7: 0:0 11:4 17:0 30:2 36:0 + +sign=-1 + 100.00 + 100 ████████████ + ████████████ + 50 ████████████ + ████████████ + 0 ────────────────────────────────────── + ▇▇▇▇▇▇▇▇▇▇▇▇ + + + -100 + + ▲ + Jan 14 Jan 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset) +4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).reversed() +5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 12:1 18:0 +1: 0:0 2:2 5:0 9:3 21:0 +2: 0:0 9:3 21:0 +3: 0:0 3:2 5:0 9:3 21:0 +4: 0:0 9:3 21:0 +5: 0:0 4:2 5:0 6:2 +6: 0:0 28:4 40:0 +7: 0:0 +8: 0:0 +9: 0:0 1:2 5:0 +10: 0:0 +11: 0:0 15:1 16:0 +12: 0:0 12:5 18:0 31:2 37:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap new file mode 100644 index 0000000000..c130c04793 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__zero_credit_history_uses_a_readable_scale.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/analytics/plot_tests.rs +expression: display(&rendered) +--- + + 1 + + 0.5 + 0 0 0 0 0 0 0.00 + 0 ─────────────────────────────────────── + ▲ + 9 10 11 12 13 14 15 + +Palette: +0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) +1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim() +2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold() +3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined() +Rows (column:palette): +0: 0:0 +1: 0:0 3:1 4:0 +2: 0:0 +3: 0:0 1:1 4:0 +4: 0:0 7:1 8:0 12:1 13:0 18:1 19:0 23:1 24:0 29:1 30:0 34:1 35:0 38:2 42:0 +5: 0:0 3:1 4:0 5:1 +6: 0:0 40:2 41:0 +7: 0:0 7:1 8:0 11:1 13:0 17:1 19:0 22:1 24:0 28:1 30:0 33:1 35:0 39:3 41:0 diff --git a/codex-rs/tui/src/analytics/styles.rs b/codex-rs/tui/src/analytics/styles.rs new file mode 100644 index 0000000000..b37737fc5c --- /dev/null +++ b/codex-rs/tui/src/analytics/styles.rs @@ -0,0 +1,37 @@ +//! Charts and legends share the terminal's ANSI palette. +//! Secondary text blends probed defaults only when the terminal supports that color depth. + +use crate::color::blend; +use crate::style::accent_style; +use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::effective_stdout_color_level; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Span; + +pub(super) fn series_colors() -> [Color; 4] { + [Color::Cyan, Color::Magenta, Color::Green, Color::Reset] +} + +pub(super) fn number(text: impl Into) -> Span<'static> { + Span::styled(text.into(), accent_style()) +} + +/// Labels and instructions need more contrast than decorative rules or inactive values. +pub(super) fn secondary_style() -> Style { + if !matches!( + effective_stdout_color_level(), + StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 + ) { + return Style::default().dim(); + } + match (default_fg(), default_bg()) { + (Some(fg), Some(bg)) => { + Style::default().fg(best_color(blend(fg, bg, /*alpha*/ 0.70))) + } + _ => Style::default().dim(), + } +}