1use ftui::render::cell::PackedRgba;
14use ftui::widgets::Widget;
15use ftui::widgets::paragraph::Paragraph;
16use ftui_extras::canvas::{CanvasRef, Mode as CanvasMode, Painter};
17use ftui_extras::charts::LineChart as FtuiLineChart;
18use ftui_extras::charts::Series as ChartSeries;
19use ftui_extras::charts::{BarChart, BarDirection, BarGroup, Sparkline};
20
21use super::app::{AnalyticsView, BreakdownTab, ExplorerMetric, ExplorerOverlay, HeatmapMetric};
22use super::ftui_adapter::{Constraint, Flex, Rect};
23use crate::sources::provenance::SourceFilter;
24
25const AGENT_COLORS: &[PackedRgba] = &[
31 PackedRgba::rgb(0, 150, 255), PackedRgba::rgb(255, 100, 0), PackedRgba::rgb(0, 200, 100), PackedRgba::rgb(200, 50, 200), PackedRgba::rgb(255, 200, 0), PackedRgba::rgb(100, 200, 255), PackedRgba::rgb(255, 80, 80), PackedRgba::rgb(150, 255, 150), PackedRgba::rgb(180, 130, 255), PackedRgba::rgb(255, 160, 200), PackedRgba::rgb(200, 200, 100), PackedRgba::rgb(100, 255, 200), PackedRgba::rgb(255, 220, 150), PackedRgba::rgb(150, 150, 255), ];
46
47fn agent_color(idx: usize) -> PackedRgba {
48 AGENT_COLORS[idx % AGENT_COLORS.len()]
49}
50
51#[derive(Clone, Copy)]
59struct ChartColors {
60 axis: PackedRgba,
62 muted: PackedRgba,
64 subtle: PackedRgba,
66 emphasis: PackedRgba,
68 tooltip_bg: PackedRgba,
70 tooltip_fg: PackedRgba,
72 highlight: PackedRgba,
74 highlight_dim: PackedRgba,
76}
77
78impl ChartColors {
79 fn for_theme(dark_mode: bool) -> Self {
80 if dark_mode {
81 Self {
82 axis: PackedRgba::rgb(190, 200, 220),
83 muted: PackedRgba::rgb(140, 140, 160),
84 subtle: PackedRgba::rgb(100, 100, 110),
85 emphasis: PackedRgba::rgb(200, 200, 200),
86 tooltip_bg: PackedRgba::rgb(60, 60, 80),
87 tooltip_fg: PackedRgba::rgb(255, 255, 255),
88 highlight: PackedRgba::rgb(255, 255, 80),
89 highlight_dim: PackedRgba::rgb(255, 200, 0),
90 }
91 } else {
92 Self {
93 axis: PackedRgba::rgb(60, 60, 80),
94 muted: PackedRgba::rgb(100, 100, 120),
95 subtle: PackedRgba::rgb(160, 160, 175),
96 emphasis: PackedRgba::rgb(40, 40, 50),
97 tooltip_bg: PackedRgba::rgb(240, 240, 245),
98 tooltip_fg: PackedRgba::rgb(20, 20, 30),
99 highlight: PackedRgba::rgb(180, 140, 0),
100 highlight_dim: PackedRgba::rgb(160, 120, 0),
101 }
102 }
103 }
104}
105
106#[derive(Clone, Debug, Default)]
114pub struct AnalyticsChartData {
115 pub agent_tokens: Vec<(String, f64)>,
117 pub agent_messages: Vec<(String, f64)>,
119 pub agent_tool_calls: Vec<(String, f64)>,
121 pub workspace_tokens: Vec<(String, f64)>,
124 pub workspace_messages: Vec<(String, f64)>,
126 pub source_tokens: Vec<(String, f64)>,
129 pub source_messages: Vec<(String, f64)>,
131 pub daily_tokens: Vec<(String, f64)>,
133 pub daily_messages: Vec<(String, f64)>,
135 pub model_tokens: Vec<(String, f64)>,
137 pub coverage_pct: f64,
139 pub total_messages: i64,
141 pub total_api_tokens: i64,
143 pub total_tool_calls: i64,
145 pub agent_count: usize,
147 pub heatmap_days: Vec<(String, f64)>,
149
150 pub total_content_tokens: i64,
153 pub daily_content_tokens: Vec<(String, f64)>,
155 pub daily_tool_calls: Vec<(String, f64)>,
157 pub total_plan_messages: i64,
159 pub daily_plan_messages: Vec<(String, f64)>,
161 pub session_scatter: Vec<crate::analytics::SessionScatterPoint>,
163 pub tool_rows: Vec<crate::analytics::ToolRow>,
166
167 pub agent_plan_messages: Vec<(String, f64)>,
170 pub plan_message_pct: f64,
172 pub plan_api_token_share: f64,
174 pub auto_rebuilt: bool,
176 pub auto_rebuild_error: Option<String>,
178}
179
180impl AnalyticsChartData {
181 pub fn is_empty(&self) -> bool {
183 self.total_api_tokens == 0
184 && self.total_messages == 0
185 && self.total_tool_calls == 0
186 && self.agent_tokens.is_empty()
187 }
188}
189
190pub fn load_chart_data(
199 db: &crate::storage::sqlite::FrankenStorage,
200 filters: &super::app::AnalyticsFilterState,
201 group_by: crate::analytics::GroupBy,
202) -> AnalyticsChartData {
203 use crate::analytics;
204
205 let conn = db.raw();
206
207 let filter = analytics::AnalyticsFilter {
209 since_ms: filters.since_ms,
210 until_ms: filters.until_ms,
211 agents: filters.agents.iter().cloned().collect(),
212 source: match &filters.source_filter {
213 SourceFilter::All => analytics::SourceFilter::All,
214 SourceFilter::Local => analytics::SourceFilter::Local,
215 SourceFilter::Remote => analytics::SourceFilter::Remote,
216 SourceFilter::SourceId(s) => analytics::SourceFilter::Specific(s.clone()),
217 },
218 workspace_ids: resolve_workspace_filter_ids(conn, &filters.workspaces),
219 };
220
221 let mut data = AnalyticsChartData::default();
222 let mut load_errors: Vec<String> = Vec::new();
223
224 match analytics::query::query_breakdown(
226 conn,
227 &filter,
228 analytics::Dim::Agent,
229 analytics::Metric::ApiTotal,
230 20,
231 ) {
232 Ok(result) => {
233 data.agent_count = result.rows.len();
234 data.agent_tokens = result
235 .rows
236 .iter()
237 .map(|r| (r.key.clone(), r.value as f64))
238 .collect();
239 data.total_api_tokens = result.rows.iter().map(|r| r.value).sum();
240 }
241 Err(e) => {
242 tracing::warn!(query = "agent_tokens", error = %e, "analytics query failed");
243 load_errors.push(format!("agent_tokens: {e}"));
244 }
245 }
246
247 macro_rules! try_analytics {
249 ($label:expr, $expr:expr, $errors:ident) => {
250 match $expr {
251 Ok(v) => Some(v),
252 Err(e) => {
253 tracing::warn!(query = $label, error = %e, "analytics query failed");
254 $errors.push(format!("{}: {e}", $label));
255 None
256 }
257 }
258 };
259 }
260
261 if let Some(result) = try_analytics!(
263 "agent_messages",
264 analytics::query::query_breakdown(
265 conn,
266 &filter,
267 analytics::Dim::Agent,
268 analytics::Metric::MessageCount,
269 20,
270 ),
271 load_errors
272 ) {
273 data.agent_messages = result
274 .rows
275 .iter()
276 .map(|r| (r.key.clone(), r.value as f64))
277 .collect();
278 data.total_messages = result.rows.iter().map(|r| r.value).sum();
279 }
280
281 if let Some(result) = try_analytics!(
283 "workspace_tokens",
284 analytics::query::query_breakdown(
285 conn,
286 &filter,
287 analytics::Dim::Workspace,
288 analytics::Metric::ApiTotal,
289 20,
290 ),
291 load_errors
292 ) {
293 data.workspace_tokens = result
294 .rows
295 .iter()
296 .map(|r| (r.key.clone(), r.value as f64))
297 .collect();
298 }
299 if let Some(result) = try_analytics!(
300 "workspace_messages",
301 analytics::query::query_breakdown(
302 conn,
303 &filter,
304 analytics::Dim::Workspace,
305 analytics::Metric::MessageCount,
306 20,
307 ),
308 load_errors
309 ) {
310 data.workspace_messages = result
311 .rows
312 .iter()
313 .map(|r| (r.key.clone(), r.value as f64))
314 .collect();
315 }
316
317 if let Some(result) = try_analytics!(
319 "source_tokens",
320 analytics::query::query_breakdown(
321 conn,
322 &filter,
323 analytics::Dim::Source,
324 analytics::Metric::ApiTotal,
325 20,
326 ),
327 load_errors
328 ) {
329 data.source_tokens = result
330 .rows
331 .iter()
332 .map(|r| (r.key.clone(), r.value as f64))
333 .collect();
334 }
335 if let Some(result) = try_analytics!(
336 "source_messages",
337 analytics::query::query_breakdown(
338 conn,
339 &filter,
340 analytics::Dim::Source,
341 analytics::Metric::MessageCount,
342 20,
343 ),
344 load_errors
345 ) {
346 data.source_messages = result
347 .rows
348 .iter()
349 .map(|r| (r.key.clone(), r.value as f64))
350 .collect();
351 }
352
353 if let Some(result) = try_analytics!(
355 "tools",
356 analytics::query::query_tools(conn, &filter, group_by, 50),
357 load_errors
358 ) {
359 data.agent_tool_calls = result
360 .rows
361 .iter()
362 .map(|r| (r.key.clone(), r.tool_call_count as f64))
363 .collect();
364 data.total_tool_calls = result.total_tool_calls;
365 data.tool_rows = result.rows;
366 }
367
368 if let Some(points) = try_analytics!(
370 "session_scatter",
371 analytics::query::query_session_scatter(conn, &filter, 600),
372 load_errors
373 ) {
374 data.session_scatter = points;
375 }
376
377 if let Some(result) = try_analytics!(
379 "timeseries",
380 analytics::query::query_tokens_timeseries(conn, &filter, group_by),
381 load_errors
382 ) {
383 data.daily_tokens = result
384 .buckets
385 .iter()
386 .map(|(label, bucket)| (label.clone(), bucket.api_tokens_total as f64))
387 .collect();
388 data.daily_messages = result
389 .buckets
390 .iter()
391 .map(|(label, bucket)| (label.clone(), bucket.message_count as f64))
392 .collect();
393 data.daily_content_tokens = result
394 .buckets
395 .iter()
396 .map(|(label, bucket)| (label.clone(), bucket.content_tokens_est_total as f64))
397 .collect();
398 data.daily_tool_calls = result
399 .buckets
400 .iter()
401 .map(|(label, bucket)| (label.clone(), bucket.tool_call_count as f64))
402 .collect();
403 data.daily_plan_messages = result
404 .buckets
405 .iter()
406 .map(|(label, bucket)| (label.clone(), bucket.plan_message_count as f64))
407 .collect();
408 data.total_content_tokens = result.totals.content_tokens_est_total;
409 data.total_plan_messages = result.totals.plan_message_count;
410
411 let max_tokens = data
413 .daily_tokens
414 .iter()
415 .map(|(_, v)| *v)
416 .fold(0.0_f64, f64::max);
417 data.heatmap_days = data
418 .daily_tokens
419 .iter()
420 .map(|(label, v)| {
421 let norm = if max_tokens > 0.0 {
422 v / max_tokens
423 } else {
424 0.0
425 };
426 (label.clone(), norm)
427 })
428 .collect();
429 }
430
431 if let Some(result) = try_analytics!(
433 "model_tokens",
434 analytics::query::query_breakdown(
435 conn,
436 &filter,
437 analytics::Dim::Model,
438 analytics::Metric::ApiTotal,
439 20,
440 ),
441 load_errors
442 ) {
443 data.model_tokens = result
444 .rows
445 .iter()
446 .map(|r| (r.key.clone(), r.value as f64))
447 .collect();
448 }
449
450 if let Some(status) = try_analytics!(
452 "status",
453 analytics::query::query_status(conn, &filter),
454 load_errors
455 ) {
456 data.coverage_pct = status.coverage.api_token_coverage_pct;
457 }
458
459 if let Some(result) = try_analytics!(
461 "plan_messages",
462 analytics::query::query_breakdown(
463 conn,
464 &filter,
465 analytics::Dim::Agent,
466 analytics::Metric::PlanCount,
467 20,
468 ),
469 load_errors
470 ) {
471 data.agent_plan_messages = result
472 .rows
473 .iter()
474 .map(|r| (r.key.clone(), r.value as f64))
475 .collect();
476 }
477
478 if !load_errors.is_empty() {
480 tracing::warn!(
481 error_count = load_errors.len(),
482 errors = ?load_errors,
483 "analytics load_chart_data had query failures — data may appear empty"
484 );
485 }
486
487 if data.total_messages > 0 {
489 data.plan_message_pct =
490 data.total_plan_messages as f64 / data.total_messages as f64 * 100.0;
491 }
492 if data.total_api_tokens > 0 {
493 let plan_token_total: f64 = data.daily_plan_messages.iter().map(|(_, v)| *v).sum();
494 if plan_token_total > 0.0 && data.total_api_tokens > 0 {
495 data.plan_api_token_share = plan_token_total / data.total_api_tokens as f64 * 100.0;
496 }
497 }
498
499 data
500}
501
502fn resolve_workspace_filter_ids(
503 conn: &frankensqlite::Connection,
504 workspaces: &std::collections::HashSet<String>,
505) -> Vec<i64> {
506 use frankensqlite::compat::{ConnectionExt, ParamValue, RowExt};
507
508 if workspaces.is_empty() {
509 return Vec::new();
510 }
511
512 let mut ids = Vec::new();
513
514 for workspace in workspaces {
515 if let Ok(id) = workspace.parse::<i64>()
516 && !ids.contains(&id)
517 {
518 ids.push(id);
519 }
520
521 if let Ok(id) = conn.query_row_map(
522 "SELECT id FROM workspaces WHERE path = ?1",
523 &[ParamValue::from(workspace.as_str())],
524 |row: &frankensqlite::Row| row.get_typed::<i64>(0),
525 ) && !ids.contains(&id)
526 {
527 ids.push(id);
528 }
529 }
530
531 ids
532}
533
534pub fn render_dashboard(
540 data: &AnalyticsChartData,
541 area: Rect,
542 frame: &mut ftui::Frame,
543 dark_mode: bool,
544) {
545 if area.height < 4 || area.width < 20 {
546 return; }
548
549 if data.is_empty() {
551 let muted = if dark_mode {
552 PackedRgba::rgb(120, 125, 140)
553 } else {
554 PackedRgba::rgb(100, 105, 115)
555 };
556 let accent = if dark_mode {
557 PackedRgba::rgb(90, 180, 255)
558 } else {
559 PackedRgba::rgb(20, 100, 200)
560 };
561 let mut lines: Vec<ftui::text::Line<'static>> = Vec::new();
562 lines.push(ftui::text::Line::from(""));
563 if area.height >= 14 && area.width >= 40 {
564 lines.push(ftui::text::Line::from_spans(vec![
565 ftui::text::Span::styled(" ▆", ftui::Style::new().fg(accent)),
566 ftui::text::Span::styled(" █", ftui::Style::new().fg(muted)),
567 ]));
568 lines.push(ftui::text::Line::from_spans(vec![
569 ftui::text::Span::styled(" ▄█", ftui::Style::new().fg(accent)),
570 ftui::text::Span::styled(" ▆ █", ftui::Style::new().fg(muted)),
571 ]));
572 lines.push(ftui::text::Line::from_spans(vec![
573 ftui::text::Span::styled(" ▆ ▄██", ftui::Style::new().fg(accent)),
574 ftui::text::Span::styled(" ▄█▄ ▆ █", ftui::Style::new().fg(muted)),
575 ]));
576 lines.push(ftui::text::Line::from_spans(vec![
577 ftui::text::Span::styled(" ▄█ ▄███", ftui::Style::new().fg(accent)),
578 ftui::text::Span::styled(" ▄███ ▄█▄ ▆ █", ftui::Style::new().fg(muted)),
579 ]));
580 lines.push(ftui::text::Line::from_spans(vec![
581 ftui::text::Span::styled(" ▄██▄ ████", ftui::Style::new().fg(accent)),
582 ftui::text::Span::styled(" █████ ▄███ ▄█▄ █", ftui::Style::new().fg(muted)),
583 ]));
584 lines.push(ftui::text::Line::from_spans(vec![
585 ftui::text::Span::styled("██████████", ftui::Style::new().fg(accent)),
586 ftui::text::Span::styled("███████████████████████", ftui::Style::new().fg(muted)),
587 ]));
588 lines.push(ftui::text::Line::from(""));
589 }
590
591 lines.push(ftui::text::Line::from_spans(vec![
592 ftui::text::Span::styled(
593 "No analytics data yet",
594 ftui::Style::new().fg(accent).bold(),
595 ),
596 ]));
597 lines.push(ftui::text::Line::from(""));
598 if area.height >= 10 {
599 lines.push(ftui::text::Line::from_spans(vec![
600 ftui::text::Span::styled(
601 "Analytics are computed from indexed sessions.",
602 ftui::Style::new().fg(muted),
603 ),
604 ]));
605 lines.push(ftui::text::Line::from(""));
606 lines.push(ftui::text::Line::from_spans(vec![
607 ftui::text::Span::styled(" 1. ", ftui::Style::new().fg(accent)),
608 ftui::text::Span::styled(
609 "Run a search to load session data",
610 ftui::Style::new().fg(muted),
611 ),
612 ]));
613 lines.push(ftui::text::Line::from_spans(vec![
614 ftui::text::Span::styled(" 2. ", ftui::Style::new().fg(accent)),
615 ftui::text::Span::styled(
616 "Press Ctrl+R to refresh the index",
617 ftui::Style::new().fg(muted),
618 ),
619 ]));
620 lines.push(ftui::text::Line::from_spans(vec![
621 ftui::text::Span::styled(" 3. ", ftui::Style::new().fg(accent)),
622 ftui::text::Span::styled(
623 "Switch between views using the tab bar above",
624 ftui::Style::new().fg(muted),
625 ),
626 ]));
627 }
628 let y_offset = area.height.saturating_sub(lines.len() as u16) / 3;
629 let avail = area.height.saturating_sub(y_offset);
630 if avail > 0 {
631 let block_area = Rect::new(
632 area.x,
633 area.y + y_offset,
634 area.width,
635 avail.min(lines.len() as u16),
636 );
637 Paragraph::new(ftui::text::Text::from_lines(lines))
638 .alignment(ftui::widgets::block::Alignment::Center)
639 .render(block_area, frame);
640 }
641 return;
642 }
643
644 let cc = ChartColors::for_theme(dark_mode);
645
646 let wide_mode = area.width >= 130;
647
648 let agent_count = data.agent_tokens.len().min(8);
650 let ws_count = data.workspace_tokens.len().min(8);
651
652 let agent_rows = if agent_count > 0 {
653 agent_count as u16 + 1
654 } else {
655 0
656 };
657 let ws_rows = if ws_count > 0 { ws_count as u16 + 1 } else { 0 };
658
659 let max_bar_rows = if wide_mode {
660 agent_rows.max(ws_rows)
661 } else {
662 agent_rows
663 };
664 let has_bar = max_bar_rows > 0 && area.height >= 6 + max_bar_rows + 4;
665
666 let chunks = if has_bar {
667 Flex::vertical()
668 .constraints([
669 Constraint::Fixed(6), Constraint::Fixed(max_bar_rows), Constraint::Fixed(2), Constraint::Min(0), ])
674 .split(area)
675 } else {
676 Flex::vertical()
677 .constraints([
678 Constraint::Fixed(6), Constraint::Fixed(2), Constraint::Min(0), ])
682 .split(area)
683 };
684
685 render_kpi_tiles(data, chunks[0], frame, dark_mode);
687
688 if has_bar {
690 let bar_area = chunks[1];
691
692 let (agent_area, ws_area) = if wide_mode {
693 let cols = Flex::horizontal()
694 .constraints([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
695 .split(bar_area);
696 (cols[0], Some(cols[1]))
697 } else {
698 (bar_area, None)
699 };
700
701 let mut render_mini_bar =
703 |items: &[(String, f64)], area: Rect, header_label: &str, use_agent_colors: bool| {
704 if area.is_empty() || items.is_empty() {
705 return;
706 }
707 let max_val = items
708 .iter()
709 .take(8)
710 .map(|(_, v)| *v)
711 .fold(0.0_f64, f64::max);
712 let label_w = items
713 .iter()
714 .take(8)
715 .map(|(name, _)| display_width(name).min(14))
716 .max()
717 .unwrap_or(6) as u16;
718
719 let header = format!(
720 " {:label_w$} tokens",
721 header_label,
722 label_w = label_w as usize
723 );
724 let header_line = ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
725 header,
726 ftui::Style::new().fg(cc.muted),
727 )]);
728 Paragraph::new(header_line).render(
729 Rect {
730 x: area.x,
731 y: area.y,
732 width: area.width,
733 height: 1,
734 },
735 frame,
736 );
737
738 let val_col = 8_u16;
739 let bar_start = area.x + 1 + label_w + 1;
740 let bar_end = area.right().saturating_sub(val_col);
741 if bar_end <= bar_start {
742 return;
743 }
744 let bar_max_w = bar_end.saturating_sub(bar_start) as f64;
745
746 for (i, (name, val)) in items.iter().take(8).enumerate() {
747 let y = area.y + 1 + i as u16;
748 if y >= area.bottom() {
749 break;
750 }
751 let color = if use_agent_colors {
752 agent_color(i)
753 } else {
754 cc.emphasis
755 };
756
757 let truncated_name = shorten_label(name, label_w as usize);
759 let val_str = format_compact(*val as i64);
760
761 let current_w = display_width(&truncated_name);
764 let pad_w = (label_w as usize).saturating_sub(current_w);
765 let pad = " ".repeat(pad_w);
766
767 let label_span = ftui::text::Span::styled(
768 format!(" {truncated_name}{pad}"),
769 ftui::Style::new().fg(cc.axis),
770 );
771 Paragraph::new(ftui::text::Line::from_spans(vec![label_span])).render(
772 Rect {
773 x: area.x,
774 y,
775 width: label_w + 1,
776 height: 1,
777 },
778 frame,
779 );
780
781 let bar_len = if max_val > 0.0 && *val > 0.0 {
782 ((val / max_val) * bar_max_w).round().max(1.0) as u16
783 } else {
784 0
785 };
786 for dx in 0..bar_len {
787 let x = bar_start + dx;
788 if x < bar_end {
789 let mut cell = ftui::render::cell::Cell::from_char('\u{2588}');
790 cell.fg = color;
791 frame.buffer.set_fast(x, y, cell);
792 }
793 }
794
795 let val_span = ftui::text::Span::styled(
796 format!(" {val_str}"),
797 ftui::Style::new().fg(cc.muted),
798 );
799 Paragraph::new(ftui::text::Line::from_spans(vec![val_span])).render(
800 Rect {
801 x: bar_end,
802 y,
803 width: val_col.min(area.right().saturating_sub(bar_end)),
804 height: 1,
805 },
806 frame,
807 );
808 }
809 };
810
811 render_mini_bar(&data.agent_tokens, agent_area, "Agent", true);
812 if let Some(w_area) = ws_area {
813 render_mini_bar(&data.workspace_tokens, w_area, "Workspace", false);
814 }
815 }
816
817 let sparkline_chunk = if has_bar { chunks[2] } else { chunks[1] };
819 if !data.daily_tokens.is_empty() && sparkline_chunk.height >= 2 {
820 let label = format!(" Daily Tokens ({} days)", data.daily_tokens.len());
822 Paragraph::new(label)
823 .style(ftui::Style::new().fg(cc.muted))
824 .render(
825 Rect {
826 x: sparkline_chunk.x,
827 y: sparkline_chunk.y,
828 width: sparkline_chunk.width,
829 height: 1,
830 },
831 frame,
832 );
833 let spark_area = Rect {
835 x: sparkline_chunk.x,
836 y: sparkline_chunk.y + 1,
837 width: sparkline_chunk.width,
838 height: sparkline_chunk.height - 1,
839 };
840 let values: Vec<f64> = data.daily_tokens.iter().map(|(_, v)| *v).collect();
841 let sparkline = Sparkline::new(&values)
842 .gradient(PackedRgba::rgb(40, 80, 200), PackedRgba::rgb(255, 80, 40));
843 sparkline.render(spark_area, frame);
844 } else if !data.daily_tokens.is_empty() {
845 let values: Vec<f64> = data.daily_tokens.iter().map(|(_, v)| *v).collect();
846 let sparkline = Sparkline::new(&values)
847 .gradient(PackedRgba::rgb(40, 80, 200), PackedRgba::rgb(255, 80, 40));
848 sparkline.render(sparkline_chunk, frame);
849 }
850}
851
852fn render_kpi_tiles(
854 data: &AnalyticsChartData,
855 area: Rect,
856 frame: &mut ftui::Frame,
857 dark_mode: bool,
858) {
859 let cc = ChartColors::for_theme(dark_mode);
860
861 let rows = Flex::vertical()
863 .constraints([Constraint::Fixed(3), Constraint::Fixed(3)])
864 .split(area);
865
866 let cols1 = Flex::horizontal()
868 .constraints([
869 Constraint::Percentage(33.0),
870 Constraint::Percentage(34.0),
871 Constraint::Percentage(33.0),
872 ])
873 .split(rows[0]);
874
875 render_kpi_tile(
876 "API Tokens",
877 &format_compact(data.total_api_tokens),
878 &data.daily_tokens,
879 PackedRgba::rgb(0, 180, 255), PackedRgba::rgb(0, 100, 200), cc.muted,
882 cols1[0],
883 frame,
884 );
885 render_kpi_tile(
886 "Messages",
887 &format_compact(data.total_messages),
888 &data.daily_messages,
889 PackedRgba::rgb(100, 220, 100), PackedRgba::rgb(40, 150, 40), cc.muted,
892 cols1[1],
893 frame,
894 );
895 render_kpi_tile(
896 "Tool Calls",
897 &format_compact(data.total_tool_calls),
898 &data.daily_tool_calls,
899 PackedRgba::rgb(255, 160, 0), PackedRgba::rgb(200, 100, 0), cc.muted,
902 cols1[2],
903 frame,
904 );
905
906 let cols2 = Flex::horizontal()
908 .constraints([
909 Constraint::Percentage(33.0),
910 Constraint::Percentage(34.0),
911 Constraint::Percentage(33.0),
912 ])
913 .split(rows[1]);
914
915 render_kpi_tile(
916 "Content Est",
917 &format_compact(data.total_content_tokens),
918 &data.daily_content_tokens,
919 PackedRgba::rgb(180, 130, 255), PackedRgba::rgb(120, 60, 200), cc.muted,
922 cols2[0],
923 frame,
924 );
925 render_kpi_tile(
926 "Plans",
927 &format_compact(data.total_plan_messages),
928 &data.daily_plan_messages,
929 PackedRgba::rgb(255, 200, 0), PackedRgba::rgb(180, 140, 0), cc.muted,
932 cols2[1],
933 frame,
934 );
935
936 render_kpi_tile(
937 "API Cvg",
938 &format!("{:.0}%", data.coverage_pct),
939 &[], PackedRgba::rgb(150, 200, 255), PackedRgba::rgb(80, 120, 180), cc.muted,
943 cols2[2],
944 frame,
945 );
946}
947
948#[allow(clippy::too_many_arguments)]
950fn render_kpi_tile(
951 label: &str,
952 value: &str,
953 sparkline_data: &[(String, f64)],
954 fg_color: PackedRgba,
955 spark_color: PackedRgba,
956 label_muted: PackedRgba,
957 area: Rect,
958 frame: &mut ftui::Frame,
959) {
960 if area.height < 2 || area.width < 8 {
961 return;
962 }
963
964 let label_area = Rect {
966 x: area.x,
967 y: area.y,
968 width: area.width,
969 height: 1,
970 };
971 Paragraph::new(format!(" {label}"))
972 .style(ftui::Style::new().fg(label_muted))
973 .render(label_area, frame);
974
975 let value_y = area.y + 1;
977 let value_str = format!(" {value}");
978 let value_width = value_str.len() as u16 + 1;
979
980 let value_area = Rect {
981 x: area.x,
982 y: value_y,
983 width: area.width.min(value_width),
984 height: 1,
985 };
986 Paragraph::new(value_str)
987 .style(ftui::Style::new().fg(fg_color).bold())
988 .render(value_area, frame);
989
990 if !sparkline_data.is_empty() && area.width > value_width + 2 {
992 let spark_x = area.x + value_width + 1;
993 let spark_w = area.width.saturating_sub(value_width + 2);
994 if spark_w >= 4 {
995 let spark_area = Rect {
996 x: spark_x,
997 y: value_y,
998 width: spark_w,
999 height: 1,
1000 };
1001 let values: Vec<f64> = sparkline_data.iter().map(|(_, v)| *v).collect();
1002 Sparkline::new(&values)
1003 .gradient(spark_color, fg_color)
1004 .render(spark_area, frame);
1005 }
1006 }
1007
1008 if area.height >= 3 && sparkline_data.len() >= 14 {
1011 let recent: f64 = sparkline_data
1012 .iter()
1013 .rev()
1014 .take(7)
1015 .map(|(_, v)| *v)
1016 .sum::<f64>();
1017 let prior: f64 = sparkline_data
1018 .iter()
1019 .rev()
1020 .skip(7)
1021 .take(7)
1022 .map(|(_, v)| *v)
1023 .sum::<f64>();
1024 let delta_area = Rect {
1025 x: area.x,
1026 y: area.y + 2,
1027 width: area.width,
1028 height: 1,
1029 };
1030 if prior > 0.0 {
1031 let pct = ((recent - prior) / prior) * 100.0;
1032 let (arrow, color) = if pct > 5.0 {
1033 ("\u{25b2}", PackedRgba::rgb(255, 80, 80)) } else if pct < -5.0 {
1035 ("\u{25bc}", PackedRgba::rgb(80, 200, 80)) } else {
1037 ("\u{25c6}", label_muted) };
1039 let delta_text = format!(" {arrow} {pct:+.0}% vs prior 7d");
1040 Paragraph::new(delta_text)
1041 .style(ftui::Style::new().fg(color))
1042 .render(delta_area, frame);
1043 }
1044 }
1045}
1046
1047fn format_compact(n: i64) -> String {
1049 let abs = n.unsigned_abs();
1050 if abs >= 1_000_000_000 {
1051 format!("{:.1}B", n as f64 / 1_000_000_000.0)
1052 } else if abs >= 1_000_000 {
1053 format!("{:.1}M", n as f64 / 1_000_000.0)
1054 } else if abs >= 10_000 {
1055 format!("{:.1}K", n as f64 / 1_000.0)
1056 } else {
1057 format_number(n)
1058 }
1059}
1060
1061pub fn render_explorer(
1063 data: &AnalyticsChartData,
1064 state: &ExplorerState,
1065 area: Rect,
1066 frame: &mut ftui::Frame,
1067 dark_mode: bool,
1068) {
1069 if area.height < 4 || area.width < 20 {
1070 return;
1071 }
1072
1073 let (metric_data, metric_color) = metric_series(data, state.metric);
1075
1076 let cc = ChartColors::for_theme(dark_mode);
1077
1078 if metric_data.is_empty() {
1079 if area.height >= 12 && area.width >= 40 {
1080 let accent = if dark_mode {
1081 PackedRgba::rgb(90, 180, 255)
1082 } else {
1083 PackedRgba::rgb(20, 100, 200)
1084 };
1085 let primary = if dark_mode {
1086 PackedRgba::rgb(60, 120, 200)
1087 } else {
1088 PackedRgba::rgb(40, 80, 160)
1089 };
1090
1091 let lines = vec![
1092 ftui::text::Line::from(""),
1093 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1094 " ▃▄▅▇██▇▅▄▃ ",
1095 ftui::Style::new().fg(accent),
1096 )]),
1097 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1098 " ▂▄▆████████████▆▄▂ ",
1099 ftui::Style::new().fg(primary),
1100 )]),
1101 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1102 " ▃▆██████████████████▆▃ ",
1103 ftui::Style::new().fg(cc.muted),
1104 )]),
1105 ftui::text::Line::from(""),
1106 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1107 " No analytics timeseries yet. If data exists, cass is rebuilding automatically.",
1108 ftui::Style::new().fg(cc.axis).bold(),
1109 )]),
1110 ];
1111 Paragraph::new(ftui::text::Text::from_lines(lines)).render(area, frame);
1112 return;
1113 }
1114
1115 Paragraph::new(
1116 " No analytics timeseries yet. If data exists, cass is rebuilding automatically.",
1117 )
1118 .style(ftui::Style::new().fg(cc.subtle))
1119 .render(area, frame);
1120 return;
1121 }
1122
1123 let chunks = Flex::vertical()
1125 .constraints([Constraint::Fixed(2), Constraint::Min(4)])
1126 .split(area);
1127
1128 let metric_total = metric_data.iter().map(|(_, v)| *v).sum::<f64>();
1130 let total_display = if metric_total >= 1_000_000_000.0 {
1131 format!("{:.1}B", metric_total / 1_000_000_000.0)
1132 } else if metric_total >= 1_000_000.0 {
1133 format!("{:.1}M", metric_total / 1_000_000.0)
1134 } else if metric_total >= 10_000.0 {
1135 format!("{:.1}K", metric_total / 1_000.0)
1136 } else {
1137 format!("{}", metric_total as i64)
1138 };
1139
1140 let date_range = if metric_data.len() >= 2 {
1141 format!(
1142 " ({} .. {})",
1143 metric_data[0].0,
1144 metric_data[metric_data.len() - 1].0
1145 )
1146 } else {
1147 String::new()
1148 };
1149
1150 let header_text = truncate_with_ellipsis(
1151 &format!(
1152 " {} ({}) | {} | Zoom: {} | Overlay: {} | Scatter: auto | m/M g/G z/Z o{}",
1153 state.metric.label(),
1154 total_display,
1155 state.group_by.label(),
1156 state.zoom.label(),
1157 state.overlay.label(),
1158 date_range,
1159 ),
1160 chunks[0].width as usize,
1161 );
1162 Paragraph::new(header_text)
1163 .style(ftui::Style::new().fg(cc.emphasis))
1164 .render(chunks[0], frame);
1165
1166 let primary_points: Vec<(f64, f64)> = metric_data
1168 .iter()
1169 .enumerate()
1170 .map(|(i, (_, v))| (i as f64, *v))
1171 .collect();
1172
1173 let mut overlay_data: Vec<Vec<(f64, f64)>> = Vec::new();
1175 let mut overlay_labels: Vec<String> = Vec::new();
1176 let mut overlay_colors: Vec<PackedRgba> = Vec::new();
1177 let dim_breakdown: Option<&[(String, f64)]> = match state.overlay {
1178 ExplorerOverlay::None => Option::None,
1179 ExplorerOverlay::ByAgent => Some(match state.metric {
1180 ExplorerMetric::Messages | ExplorerMetric::PlanMessages => &data.agent_messages,
1181 ExplorerMetric::ToolCalls => &data.agent_tool_calls,
1182 _ => &data.agent_tokens,
1183 }),
1184 ExplorerOverlay::ByWorkspace => Some(match state.metric {
1185 ExplorerMetric::Messages | ExplorerMetric::PlanMessages => &data.workspace_messages,
1186 _ => &data.workspace_tokens,
1187 }),
1188 ExplorerOverlay::BySource => Some(match state.metric {
1189 ExplorerMetric::Messages | ExplorerMetric::PlanMessages => &data.source_messages,
1190 _ => &data.source_tokens,
1191 }),
1192 };
1193 if let Some(breakdown) = dim_breakdown
1194 && !breakdown.is_empty()
1195 {
1196 overlay_data = build_dimension_overlay(breakdown, metric_data);
1197 for (i, points) in overlay_data.iter().enumerate().take(5) {
1198 if !points.is_empty() {
1199 let name = breakdown.get(i).map(|(n, _)| n.as_str()).unwrap_or("other");
1200 overlay_labels.push(name.to_string());
1201 overlay_colors.push(agent_color(i));
1202 }
1203 }
1204 }
1205
1206 let x_labels: Vec<&str> = if metric_data.len() >= 3 {
1208 vec![
1209 &metric_data[0].0,
1210 &metric_data[metric_data.len() / 2].0,
1211 &metric_data[metric_data.len() - 1].0,
1212 ]
1213 } else if !metric_data.is_empty() {
1214 vec![&metric_data[0].0, &metric_data[metric_data.len() - 1].0]
1215 } else {
1216 vec![]
1217 };
1218
1219 let chart_body = chunks[1];
1220 let show_scatter =
1221 chart_body.height >= 10 && chart_body.width >= 50 && !data.session_scatter.is_empty();
1222 if show_scatter {
1223 let sub = Flex::vertical()
1224 .constraints([Constraint::Percentage(65.0), Constraint::Percentage(35.0)])
1225 .split(chart_body);
1226 render_explorer_line_canvas(
1227 state.metric,
1228 metric_data,
1229 &primary_points,
1230 metric_color,
1231 &overlay_data,
1232 &overlay_labels,
1233 &overlay_colors,
1234 &x_labels,
1235 sub[0],
1236 frame,
1237 cc,
1238 );
1239 render_explorer_scatter(&data.session_scatter, sub[1], frame, cc);
1240 } else {
1241 render_explorer_line_canvas(
1242 state.metric,
1243 metric_data,
1244 &primary_points,
1245 metric_color,
1246 &overlay_data,
1247 &overlay_labels,
1248 &overlay_colors,
1249 &x_labels,
1250 chart_body,
1251 frame,
1252 cc,
1253 );
1254 }
1255}
1256
1257#[allow(clippy::too_many_arguments)]
1258fn render_explorer_line_canvas(
1259 metric: ExplorerMetric,
1260 metric_data: &[(String, f64)],
1261 primary_points: &[(f64, f64)],
1262 primary_color: PackedRgba,
1263 overlay_data: &[Vec<(f64, f64)>],
1264 overlay_labels: &[String],
1265 overlay_colors: &[PackedRgba],
1266 x_labels: &[&str],
1267 area: Rect,
1268 frame: &mut ftui::Frame,
1269 cc: ChartColors,
1270) {
1271 if area.height < 5 || area.width < 20 {
1272 let mut series = vec![ChartSeries::new(
1273 metric.label(),
1274 primary_points,
1275 primary_color,
1276 )];
1277 for (idx, points) in overlay_data.iter().enumerate() {
1278 if points.is_empty() {
1279 continue;
1280 }
1281 let name = overlay_labels
1282 .get(idx)
1283 .map(String::as_str)
1284 .unwrap_or("overlay");
1285 let color = overlay_colors
1286 .get(idx)
1287 .copied()
1288 .unwrap_or_else(|| agent_color(idx));
1289 series.push(ChartSeries::new(name, points, color).markers(true));
1290 }
1291 FtuiLineChart::new(series)
1292 .x_labels(x_labels.to_vec())
1293 .legend(true)
1294 .render(area, frame);
1295 return;
1296 }
1297
1298 let chunks = Flex::vertical()
1299 .constraints([Constraint::Fixed(1), Constraint::Min(4)])
1300 .split(area);
1301 let annotation = truncate_with_ellipsis(
1302 &build_explorer_annotation_line(metric, metric_data, overlay_labels),
1303 chunks[0].width as usize,
1304 );
1305 Paragraph::new(annotation)
1306 .style(ftui::Style::new().fg(cc.muted))
1307 .render(chunks[0], frame);
1308
1309 let chart_outer = chunks[1];
1310 if chart_outer.height < 4 || chart_outer.width < 12 {
1311 return;
1312 }
1313
1314 let mut y_max = primary_points
1315 .iter()
1316 .map(|(_, y)| *y)
1317 .fold(0.0_f64, f64::max);
1318 for points in overlay_data {
1319 for (_, y) in points {
1320 y_max = y_max.max(*y);
1321 }
1322 }
1323 if y_max <= 0.0 {
1324 y_max = 1.0;
1325 }
1326
1327 let top_label = format_explorer_metric_value(metric, y_max);
1328 let bottom_label = format_explorer_metric_value(metric, 0.0);
1329 let y_axis_w = (display_width(&top_label).max(display_width(&bottom_label)) as u16 + 1)
1330 .min(chart_outer.width.saturating_sub(6))
1331 .max(1);
1332 let x_axis_h = 2u16;
1333 if chart_outer.height <= x_axis_h || chart_outer.width <= y_axis_w + 3 {
1334 return;
1335 }
1336 let plot_area = Rect {
1337 x: chart_outer.x + y_axis_w,
1338 y: chart_outer.y,
1339 width: chart_outer.width.saturating_sub(y_axis_w),
1340 height: chart_outer.height.saturating_sub(x_axis_h),
1341 };
1342 if plot_area.width < 2 || plot_area.height < 2 {
1343 return;
1344 }
1345
1346 let mut painter = Painter::for_area(plot_area, CanvasMode::Braille);
1347 let (px_w, px_h) = painter.size();
1348 if px_w < 2 || px_h < 2 {
1349 return;
1350 }
1351 let px_w = i32::from(px_w);
1352 let px_h = i32::from(px_h);
1353 let x_max = if primary_points.len() > 1 {
1354 primary_points.len() as f64 - 1.0
1355 } else {
1356 1.0
1357 };
1358 let y_range = y_max.max(1.0);
1359 let to_px = |x: f64, y: f64| -> (i32, i32) {
1360 let px = ((x / x_max) * (px_w as f64 - 1.0)).round() as i32;
1361 let py = (((y_max - y) / y_range) * (px_h as f64 - 1.0)).round() as i32;
1362 (px.clamp(0, px_w - 1), py.clamp(0, px_h - 1))
1363 };
1364
1365 let baseline = px_h - 1;
1366 let fill_color = dim_color(primary_color, 0.35);
1367 if primary_points.len() >= 2 {
1368 for window in primary_points.windows(2) {
1369 let (x0, y0) = to_px(window[0].0, window[0].1);
1370 let (x1, y1) = to_px(window[1].0, window[1].1);
1371 if x0 == x1 {
1372 painter.line_colored(x0, (y0 + 1).min(baseline), x0, baseline, Some(fill_color));
1373 } else {
1374 let (start, end, ys, ye) = if x0 < x1 {
1375 (x0, x1, y0, y1)
1376 } else {
1377 (x1, x0, y1, y0)
1378 };
1379 for x in start..=end {
1380 let t = if end == start {
1381 0.0
1382 } else {
1383 (x - start) as f64 / (end - start) as f64
1384 };
1385 let y = (ys as f64 + (ye - ys) as f64 * t).round() as i32;
1386 painter.line_colored(x, (y + 1).min(baseline), x, baseline, Some(fill_color));
1387 }
1388 }
1389 }
1390 }
1391
1392 if let Some((x, y)) = primary_points.first() {
1393 let (px, py) = to_px(*x, *y);
1394 painter.point_colored(px, py, primary_color);
1395 }
1396
1397 for (idx, points) in overlay_data.iter().enumerate() {
1398 let color = overlay_colors
1399 .get(idx)
1400 .copied()
1401 .unwrap_or_else(|| agent_color(idx));
1402 for window in points.windows(2) {
1403 let (x0, y0) = to_px(window[0].0, window[0].1);
1404 let (x1, y1) = to_px(window[1].0, window[1].1);
1405 painter.line_colored(x0, y0, x1, y1, Some(color));
1406 }
1407 for (x, y) in points.iter().step_by(4) {
1408 let (px, py) = to_px(*x, *y);
1409 painter.point_colored(px, py, color);
1410 }
1411 }
1412
1413 if !primary_points.is_empty() {
1414 let avg = primary_points.iter().map(|(_, y)| *y).sum::<f64>() / primary_points.len() as f64;
1415 let (_, avg_y) = to_px(0.0, avg);
1416 painter.line_colored(0, avg_y, px_w - 1, avg_y, Some(cc.subtle));
1417 if let Some((peak_idx, (_, peak_val))) = primary_points.iter().enumerate().max_by(|a, b| {
1418 a.1.1
1419 .partial_cmp(&b.1.1)
1420 .unwrap_or(std::cmp::Ordering::Equal)
1421 }) {
1422 let (peak_x, peak_y) = to_px(peak_idx as f64, *peak_val);
1423 for d in -1..=1 {
1424 painter.point_colored(peak_x + d, peak_y, cc.highlight);
1425 painter.point_colored(peak_x, peak_y + d, cc.highlight);
1426 }
1427 }
1428 }
1429
1430 let canvas = CanvasRef::from_painter(&painter).style(ftui::Style::new().fg(cc.axis));
1431 canvas.render(plot_area, frame);
1432
1433 let axis_color = cc.muted;
1434 let y_axis_x = plot_area.x.saturating_sub(1);
1435 for y in plot_area.y..plot_area.y + plot_area.height {
1436 Paragraph::new("│")
1437 .style(ftui::Style::new().fg(axis_color))
1438 .render(
1439 Rect {
1440 x: y_axis_x,
1441 y,
1442 width: 1,
1443 height: 1,
1444 },
1445 frame,
1446 );
1447 }
1448 let x_axis_y = plot_area.y + plot_area.height.saturating_sub(1);
1449 for x in plot_area.x..plot_area.x + plot_area.width {
1450 Paragraph::new("─")
1451 .style(ftui::Style::new().fg(axis_color))
1452 .render(
1453 Rect {
1454 x,
1455 y: x_axis_y,
1456 width: 1,
1457 height: 1,
1458 },
1459 frame,
1460 );
1461 }
1462 Paragraph::new("└")
1463 .style(ftui::Style::new().fg(axis_color))
1464 .render(
1465 Rect {
1466 x: y_axis_x,
1467 y: x_axis_y,
1468 width: 1,
1469 height: 1,
1470 },
1471 frame,
1472 );
1473
1474 Paragraph::new(top_label)
1475 .style(ftui::Style::new().fg(cc.muted))
1476 .render(
1477 Rect {
1478 x: chart_outer.x,
1479 y: chart_outer.y,
1480 width: y_axis_w,
1481 height: 1,
1482 },
1483 frame,
1484 );
1485 Paragraph::new(bottom_label)
1486 .style(ftui::Style::new().fg(cc.muted))
1487 .render(
1488 Rect {
1489 x: chart_outer.x,
1490 y: x_axis_y,
1491 width: y_axis_w,
1492 height: 1,
1493 },
1494 frame,
1495 );
1496
1497 if !x_labels.is_empty() {
1498 let label_y = chart_outer.y + chart_outer.height.saturating_sub(1);
1499 let slots = x_labels.len().saturating_sub(1).max(1) as u16;
1500 let mut last_label_end = plot_area.x;
1501 for (idx, label) in x_labels.iter().enumerate() {
1502 if label.is_empty() {
1503 continue;
1504 }
1505 let label_text = truncate_with_ellipsis(label, plot_area.width as usize);
1506 let width = (display_width(&label_text) as u16).min(plot_area.width);
1507 if width == 0 {
1508 continue;
1509 }
1510 let raw_x = if idx == 0 {
1511 plot_area.x
1512 } else if idx + 1 == x_labels.len() {
1513 plot_area.x + plot_area.width.saturating_sub(width)
1514 } else {
1515 let t = (idx as u16).saturating_mul(plot_area.width.saturating_sub(1)) / slots;
1516 plot_area.x + t.saturating_sub(width / 2)
1517 };
1518 let x = raw_x.clamp(
1519 plot_area.x,
1520 plot_area.x + plot_area.width.saturating_sub(width),
1521 );
1522 if x < last_label_end {
1524 continue;
1525 }
1526 Paragraph::new(label_text)
1527 .style(ftui::Style::new().fg(cc.muted))
1528 .render(
1529 Rect {
1530 x,
1531 y: label_y,
1532 width,
1533 height: 1,
1534 },
1535 frame,
1536 );
1537 last_label_end = x.saturating_add(width.saturating_add(1));
1538 }
1539 }
1540}
1541
1542fn render_explorer_scatter(
1543 points: &[crate::analytics::SessionScatterPoint],
1544 area: Rect,
1545 frame: &mut ftui::Frame,
1546 cc: ChartColors,
1547) {
1548 if area.height < 4 || area.width < 24 {
1549 return;
1550 }
1551 if points.is_empty() {
1552 Paragraph::new(" Scatter: no per-session data")
1553 .style(ftui::Style::new().fg(cc.subtle))
1554 .render(area, frame);
1555 return;
1556 }
1557
1558 let chunks = Flex::vertical()
1559 .constraints([Constraint::Fixed(1), Constraint::Min(3)])
1560 .split(area);
1561
1562 let valid: Vec<&crate::analytics::SessionScatterPoint> = points
1563 .iter()
1564 .filter(|p| p.message_count > 0 && p.api_tokens_total >= 0)
1565 .collect();
1566 if valid.is_empty() {
1567 Paragraph::new(" Scatter: no positive session points")
1568 .style(ftui::Style::new().fg(cc.subtle))
1569 .render(area, frame);
1570 return;
1571 }
1572
1573 let avg_messages =
1574 valid.iter().map(|p| p.message_count as f64).sum::<f64>() / valid.len() as f64;
1575 let avg_tokens =
1576 valid.iter().map(|p| p.api_tokens_total as f64).sum::<f64>() / valid.len() as f64;
1577 let avg_efficiency = if avg_messages > 0.0 {
1578 avg_tokens / avg_messages
1579 } else {
1580 0.0
1581 };
1582 let header = truncate_with_ellipsis(
1583 &format!(
1584 " Scatter: session tokens vs messages ({}) avg tok/msg {}",
1585 valid.len(),
1586 format_compact(avg_efficiency.round() as i64)
1587 ),
1588 chunks[0].width as usize,
1589 );
1590 Paragraph::new(header)
1591 .style(ftui::Style::new().fg(cc.axis))
1592 .render(chunks[0], frame);
1593
1594 let plot_area = chunks[1];
1595 if plot_area.width < 4 || plot_area.height < 2 {
1596 return;
1597 }
1598 let mut painter = Painter::for_area(plot_area, CanvasMode::HalfBlock);
1599 let (px_w, px_h) = painter.size();
1600 if px_w < 3 || px_h < 3 {
1601 return;
1602 }
1603 let px_w = i32::from(px_w);
1604 let px_h = i32::from(px_h);
1605
1606 let max_messages = valid
1607 .iter()
1608 .map(|p| p.message_count)
1609 .max()
1610 .unwrap_or(1)
1611 .max(1) as f64;
1612 let max_tokens = valid
1613 .iter()
1614 .map(|p| p.api_tokens_total)
1615 .max()
1616 .unwrap_or(1)
1617 .max(1) as f64;
1618 let to_px = |messages: f64, tokens: f64| -> (i32, i32) {
1619 let x = ((messages / max_messages) * (px_w as f64 - 1.0)).round() as i32;
1620 let y = (((max_tokens - tokens) / max_tokens) * (px_h as f64 - 1.0)).round() as i32;
1621 (x.clamp(0, px_w - 1), y.clamp(0, px_h - 1))
1622 };
1623
1624 let baseline = px_h - 1;
1626 painter.line_colored(0, baseline, px_w - 1, baseline, Some(cc.subtle));
1627 painter.line_colored(0, 0, 0, px_h - 1, Some(cc.subtle));
1628 let (avg_x, avg_y) = to_px(avg_messages, avg_tokens);
1629 painter.line_colored(avg_x, 0, avg_x, px_h - 1, Some(cc.muted));
1630 painter.line_colored(0, avg_y, px_w - 1, avg_y, Some(cc.muted));
1631
1632 for point in valid {
1633 let ratio = point.api_tokens_total as f64 / point.message_count.max(1) as f64;
1634 let color = if ratio > avg_efficiency * 1.25 {
1635 PackedRgba::rgb(255, 150, 60)
1636 } else if ratio < avg_efficiency * 0.75 {
1637 PackedRgba::rgb(90, 220, 120)
1638 } else {
1639 PackedRgba::rgb(120, 190, 255)
1640 };
1641 let (x, y) = to_px(point.message_count as f64, point.api_tokens_total as f64);
1642 for dy in -1..=1 {
1643 for dx in -1..=1 {
1644 if dx * dx + dy * dy <= 1 {
1645 painter.point_colored(x + dx, y + dy, color);
1646 }
1647 }
1648 }
1649 }
1650
1651 let canvas = CanvasRef::from_painter(&painter).style(ftui::Style::new().fg(cc.axis));
1652 canvas.render(plot_area, frame);
1653}
1654
1655fn dim_color(color: PackedRgba, factor: f32) -> PackedRgba {
1656 let f = factor.clamp(0.0, 1.0);
1657 PackedRgba::rgb(
1658 (color.r() as f32 * f) as u8,
1659 (color.g() as f32 * f) as u8,
1660 (color.b() as f32 * f) as u8,
1661 )
1662}
1663
1664fn format_explorer_metric_value(metric: ExplorerMetric, value: f64) -> String {
1665 let _ = metric; format_compact(value.round() as i64)
1667}
1668
1669fn build_explorer_annotation_line(
1670 metric: ExplorerMetric,
1671 metric_data: &[(String, f64)],
1672 overlay_labels: &[String],
1673) -> String {
1674 if metric_data.is_empty() {
1675 return " No explorer data".to_string();
1676 }
1677 let mut peak_idx = 0usize;
1678 let mut peak_val = metric_data[0].1;
1679 for (idx, (_, value)) in metric_data.iter().enumerate() {
1680 if *value > peak_val {
1681 peak_val = *value;
1682 peak_idx = idx;
1683 }
1684 }
1685 let avg = metric_data.iter().map(|(_, value)| *value).sum::<f64>() / metric_data.len() as f64;
1686 let first = metric_data.first().map(|(_, v)| *v).unwrap_or(0.0);
1687 let last = metric_data.last().map(|(_, v)| *v).unwrap_or(0.0);
1688 let trend_pct = if first.abs() > f64::EPSILON {
1689 ((last - first) / first) * 100.0
1690 } else {
1691 0.0
1692 };
1693
1694 let mut line = format!(
1695 " Peak {} ({}) | Avg {} | Trend {:+.1}%",
1696 format_explorer_metric_value(metric, peak_val),
1697 metric_data
1698 .get(peak_idx)
1699 .map(|(label, _)| label.as_str())
1700 .unwrap_or("-"),
1701 format_explorer_metric_value(metric, avg),
1702 trend_pct
1703 );
1704 if !overlay_labels.is_empty() {
1705 let preview = overlay_labels
1706 .iter()
1707 .take(3)
1708 .map(String::as_str)
1709 .collect::<Vec<_>>()
1710 .join(", ");
1711 line.push_str(&format!(" | Top overlay: {preview}"));
1712 }
1713 line
1714}
1715
1716fn metric_series(
1718 data: &AnalyticsChartData,
1719 metric: ExplorerMetric,
1720) -> (&[(String, f64)], PackedRgba) {
1721 match metric {
1722 ExplorerMetric::ApiTokens => (&data.daily_tokens, PackedRgba::rgb(0, 150, 255)),
1723 ExplorerMetric::ContentTokens => {
1724 (&data.daily_content_tokens, PackedRgba::rgb(180, 130, 255))
1725 }
1726 ExplorerMetric::Messages => (&data.daily_messages, PackedRgba::rgb(100, 220, 100)),
1727 ExplorerMetric::ToolCalls => (&data.daily_tool_calls, PackedRgba::rgb(255, 160, 0)),
1728 ExplorerMetric::PlanMessages => (&data.daily_plan_messages, PackedRgba::rgb(255, 200, 0)),
1729 }
1730}
1731
1732fn build_dimension_overlay(
1738 breakdown: &[(String, f64)],
1739 daily_series: &[(String, f64)],
1740) -> Vec<Vec<(f64, f64)>> {
1741 let total: f64 = breakdown.iter().map(|(_, v)| *v).sum();
1742 if total <= 0.0 {
1743 return vec![];
1744 }
1745
1746 breakdown
1747 .iter()
1748 .take(5)
1749 .map(|(_, item_total)| {
1750 let share = item_total / total;
1751 daily_series
1752 .iter()
1753 .enumerate()
1754 .map(|(i, (_, day_val))| (i as f64, day_val * share))
1755 .collect()
1756 })
1757 .collect()
1758}
1759
1760fn heatmap_series_for_metric(
1764 data: &AnalyticsChartData,
1765 metric: HeatmapMetric,
1766) -> (Vec<(String, f64)>, f64, f64) {
1767 if matches!(metric, HeatmapMetric::Coverage) {
1768 if data.heatmap_days.is_empty() {
1769 return (Vec::new(), 0.0, 0.0);
1770 }
1771 let min_norm = data
1772 .heatmap_days
1773 .iter()
1774 .map(|(_, v)| *v)
1775 .fold(f64::INFINITY, f64::min);
1776 let max_norm = data
1777 .heatmap_days
1778 .iter()
1779 .map(|(_, v)| *v)
1780 .fold(0.0_f64, f64::max);
1781 return (
1782 data.heatmap_days.clone(),
1783 min_norm * 100.0,
1784 max_norm * 100.0,
1785 );
1786 }
1787
1788 let raw: &[(String, f64)] = match metric {
1789 HeatmapMetric::ApiTokens => &data.daily_tokens,
1790 HeatmapMetric::Messages => &data.daily_messages,
1791 HeatmapMetric::ContentTokens => &data.daily_content_tokens,
1792 HeatmapMetric::ToolCalls => &data.daily_tool_calls,
1793 HeatmapMetric::Coverage => &[],
1794 };
1795 if raw.is_empty() {
1796 return (Vec::new(), 0.0, 0.0);
1797 }
1798 let max_val = raw.iter().map(|(_, v)| *v).fold(0.0_f64, f64::max);
1799 let min_val = raw.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min);
1800 let series = raw
1801 .iter()
1802 .map(|(label, v)| {
1803 let norm = if max_val > 0.0 { v / max_val } else { 0.0 };
1804 (label.clone(), norm)
1805 })
1806 .collect();
1807 (series, min_val, max_val)
1808}
1809
1810fn format_heatmap_value(val: f64, metric: HeatmapMetric) -> String {
1812 match metric {
1813 HeatmapMetric::Coverage => format!("{:.0}%", val),
1814 _ => {
1815 let abs = val.abs() as i64;
1816 format_compact(abs)
1817 }
1818 }
1819}
1820
1821const DOW_LABELS: [&str; 7] = ["Mon", "", "Wed", "", "Fri", "", ""];
1823
1824fn parse_day_label(label: &str) -> Option<(i32, u32, u32)> {
1826 let parts: Vec<&str> = label.split('-').collect();
1827 if parts.len() != 3 {
1828 return None;
1829 }
1830 let y: i32 = parts[0].parse().ok()?;
1831 let m: u32 = parts[1].parse().ok()?;
1832 let d: u32 = parts[2].parse().ok()?;
1833 Some((y, m, d))
1834}
1835
1836#[allow(dead_code)] fn weekday_index(y: i32, m: u32, d: u32) -> usize {
1839 static T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
1840 let y = if m < 3 { y - 1 } else { y };
1841 let m_idx = (m as usize).clamp(1, 12) - 1;
1842 let dow = (y + y / 4 - y / 100 + y / 400 + T[m_idx] + d as i32) % 7;
1843 ((dow + 6) % 7) as usize
1845}
1846
1847pub fn render_heatmap(
1850 data: &AnalyticsChartData,
1851 metric: HeatmapMetric,
1852 selection: usize,
1853 area: Rect,
1854 frame: &mut ftui::Frame,
1855 dark_mode: bool,
1856) {
1857 let (series, min_raw, max_raw) = heatmap_series_for_metric(data, metric);
1858 let cc = ChartColors::for_theme(dark_mode);
1859
1860 if series.is_empty() {
1861 if area.height >= 12 && area.width >= 40 {
1862 let muted = if dark_mode {
1863 PackedRgba::rgb(120, 125, 140)
1864 } else {
1865 PackedRgba::rgb(100, 105, 115)
1866 };
1867 let accent = if dark_mode {
1868 PackedRgba::rgb(90, 180, 255)
1869 } else {
1870 PackedRgba::rgb(20, 100, 200)
1871 };
1872 let primary = if dark_mode {
1873 PackedRgba::rgb(60, 120, 200)
1874 } else {
1875 PackedRgba::rgb(40, 80, 160)
1876 };
1877 let lines = vec![
1878 ftui::text::Line::from(""),
1879 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1880 " ░░░ ▒▒▒ ▓▓▓ ███ ▓▓▓ ▒▒▒ ░░░",
1881 ftui::Style::new().fg(muted),
1882 )]),
1883 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1884 " ▒▒▒ ▓▓▓ ███ ███ ███ ▓▓▓ ▒▒▒",
1885 ftui::Style::new().fg(primary),
1886 )]),
1887 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1888 " ▓▓▓ ███ ███ ███ ███ ███ ▓▓▓",
1889 ftui::Style::new().fg(accent),
1890 )]),
1891 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1892 " ███ ███ ███ ███ ███ ███ ███",
1893 ftui::Style::new().fg(accent),
1894 )]),
1895 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1896 " ▓▓▓ ███ ███ ███ ███ ███ ▓▓▓",
1897 ftui::Style::new().fg(accent),
1898 )]),
1899 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1900 " ▒▒▒ ▓▓▓ ███ ███ ███ ▓▓▓ ▒▒▒",
1901 ftui::Style::new().fg(primary),
1902 )]),
1903 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1904 " ░░░ ▒▒▒ ▓▓▓ ███ ▓▓▓ ▒▒▒ ░░░",
1905 ftui::Style::new().fg(muted),
1906 )]),
1907 ftui::text::Line::from(""),
1908 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
1909 " No daily data available for this view yet.",
1910 ftui::Style::new().fg(cc.axis).bold(),
1911 )]),
1912 ];
1913 Paragraph::new(ftui::text::Text::from_lines(lines)).render(area, frame);
1914 return;
1915 }
1916
1917 Paragraph::new(" No daily data available for this view yet.")
1918 .style(ftui::Style::new().fg(cc.subtle))
1919 .render(area, frame);
1920 return;
1921 }
1922
1923 let min_body = 5u16;
1925 if area.height < 4 {
1926 let vals: Vec<f64> = series.iter().map(|(_, v)| *v).collect();
1928 let spark =
1929 Sparkline::new(&vals).style(ftui::Style::new().fg(PackedRgba::rgb(80, 200, 120)));
1930 spark.render(area, frame);
1931 return;
1932 }
1933
1934 let show_legend = area.height >= min_body + 3;
1935 let legend_h = if show_legend { 1 } else { 0 };
1936 let chunks = Flex::vertical()
1937 .constraints([
1938 Constraint::Fixed(1), Constraint::Fixed(1), Constraint::Min(min_body), Constraint::Fixed(legend_h), ])
1943 .split(area);
1944 let tab_area = chunks[0];
1945 let month_area = chunks[1];
1946 let grid_area = chunks[2];
1947 let legend_area = chunks[3];
1948
1949 render_heatmap_tabs(metric, tab_area, frame, cc);
1951
1952 let left_gutter = 4u16; let grid_inner = Rect {
1955 x: grid_area.x + left_gutter,
1956 y: grid_area.y,
1957 width: grid_area.width.saturating_sub(left_gutter),
1958 height: grid_area.height,
1959 };
1960
1961 let rows = 7u16; let day_count = (series.len().min(u16::MAX as usize)) as u16;
1963 let cols = day_count.div_ceil(rows);
1964
1965 let max_cols = grid_inner.width / 2;
1968 let visible_cols = cols.min(max_cols).max(1);
1969 let skip_cols = cols.saturating_sub(visible_cols);
1971 let skip_days = (skip_cols * rows) as usize;
1972
1973 let cell_w = grid_inner.width.checked_div(visible_cols).unwrap_or(1);
1974 let cell_h = grid_inner.height.checked_div(rows).unwrap_or(1);
1975 let cell_h = cell_h.max(1);
1976 let cell_w = cell_w.max(1);
1977
1978 for (r, label) in DOW_LABELS.iter().enumerate() {
1980 if !label.is_empty() && (r as u16) < grid_area.height {
1981 let label_rect = Rect {
1982 x: grid_area.x,
1983 y: grid_area.y + (r as u16) * cell_h,
1984 width: left_gutter,
1985 height: 1,
1986 };
1987 Paragraph::new(*label)
1988 .style(ftui::Style::new().fg(cc.muted))
1989 .render(label_rect, frame);
1990 }
1991 }
1992
1993 {
1995 let month_inner = Rect {
1996 x: month_area.x + left_gutter,
1997 y: month_area.y,
1998 width: month_area.width.saturating_sub(left_gutter),
1999 height: 1,
2000 };
2001 let month_names = [
2002 "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
2003 ];
2004 let mut last_month = 0u32;
2005 for (i, (label, _)) in series.iter().enumerate().skip(skip_days) {
2006 let local_i = (i - skip_days) as u16;
2007 let col = local_i / rows;
2008 if col >= visible_cols {
2009 break;
2010 }
2011 let row = local_i % rows;
2012 if row != 0 {
2013 continue; }
2015 if let Some((_, m, _)) = parse_day_label(label)
2016 && m != last_month
2017 {
2018 last_month = m;
2019 let x = month_inner.x + col * cell_w;
2020 if x + 3 <= month_inner.x + month_inner.width {
2021 let mname = month_names.get(m as usize).unwrap_or(&"");
2022 let mr = Rect {
2023 x,
2024 y: month_inner.y,
2025 width: 3.min(month_inner.width.saturating_sub(x - month_inner.x)),
2026 height: 1,
2027 };
2028 Paragraph::new(*mname)
2029 .style(ftui::Style::new().fg(cc.emphasis))
2030 .render(mr, frame);
2031 }
2032 }
2033 }
2034 }
2035
2036 let mut painter = Painter::for_area(grid_inner, CanvasMode::HalfBlock);
2038
2039 for (i, (_, value)) in series.iter().enumerate().skip(skip_days) {
2040 let local_i = (i - skip_days) as u16;
2041 let col = local_i / rows;
2042 if col >= visible_cols {
2043 break;
2044 }
2045 let row = local_i % rows;
2046 let px = (col * cell_w) as i32;
2047 let py = (row * cell_h) as i32;
2048 let color = ftui_extras::charts::heatmap_gradient(*value);
2049 let fw = (cell_w.max(1) as i32).saturating_sub(1).max(1); let fh = cell_h.max(1) as i32; for dy in 0..fh {
2052 for dx in 0..fw {
2053 painter.point_colored(px + dx, py + dy, color);
2054 }
2055 }
2056 }
2057
2058 let canvas = CanvasRef::from_painter(&painter).style(ftui::Style::new().fg(cc.emphasis));
2059 canvas.render(grid_inner, frame);
2060
2061 if selection < series.len() && selection >= skip_days {
2063 let local_sel = (selection - skip_days) as u16;
2064 let sel_col = local_sel / rows;
2065 let sel_row = local_sel % rows;
2066 if sel_col < visible_cols {
2067 let sx = grid_inner.x + sel_col * cell_w;
2068 let sy = grid_inner.y + sel_row * cell_h;
2069 let sw = cell_w.min((grid_inner.x + grid_inner.width).saturating_sub(sx));
2070 let sh = cell_h.min((grid_inner.y + grid_inner.height).saturating_sub(sy));
2071 if sw > 0 && sh > 0 {
2072 let sel_rect = Rect {
2073 x: sx,
2074 y: sy,
2075 width: sw,
2076 height: sh,
2077 };
2078 let marker = if sw >= 2 {
2080 "\u{25a0}".to_string() } else {
2082 "\u{25b6}".to_string() };
2084 Paragraph::new(marker)
2085 .style(ftui::Style::new().fg(cc.highlight).bold())
2086 .render(sel_rect, frame);
2087 }
2088 }
2089 }
2090
2091 if selection < series.len() {
2093 let (label, norm) = &series[selection];
2094 let raw_val = if matches!(metric, HeatmapMetric::Coverage) {
2097 norm * 100.0
2098 } else {
2099 norm * max_raw
2100 };
2101 let val_str = format_heatmap_value(raw_val, metric);
2102 let tip = format!(" {} : {} ", label, val_str);
2103 let tip_w = display_width(&tip) as u16;
2104 if grid_inner.width >= tip_w {
2106 let tip_rect = Rect {
2107 x: grid_inner.x + grid_inner.width - tip_w,
2108 y: grid_area.y + grid_area.height.saturating_sub(1),
2109 width: tip_w,
2110 height: 1,
2111 };
2112 Paragraph::new(tip)
2113 .style(ftui::Style::new().fg(cc.tooltip_fg).bg(cc.tooltip_bg))
2114 .render(tip_rect, frame);
2115 }
2116 }
2117
2118 if show_legend && legend_area.height > 0 {
2120 let min_str = format_heatmap_value(min_raw, metric);
2121 let max_str = format_heatmap_value(max_raw, metric);
2122 let label_left = format!(" {} ", min_str);
2123 let label_right = format!(" {} ", max_str);
2124 let ll = label_left.len() as u16;
2125 let lr = label_right.len() as u16;
2126
2127 let left_rect = Rect {
2129 x: legend_area.x + left_gutter,
2130 y: legend_area.y,
2131 width: ll.min(legend_area.width),
2132 height: 1,
2133 };
2134 Paragraph::new(label_left)
2135 .style(ftui::Style::new().fg(cc.muted))
2136 .render(left_rect, frame);
2137
2138 let ramp_x = left_rect.x + ll;
2140 let ramp_end = legend_area.x + legend_area.width.saturating_sub(lr);
2141 let ramp_w = ramp_end.saturating_sub(ramp_x);
2142 if ramp_w > 0 {
2143 for dx in 0..ramp_w {
2144 let t = dx as f64 / ramp_w.max(1) as f64;
2145 let color = ftui_extras::charts::heatmap_gradient(t);
2146 let cell_rect = Rect {
2147 x: ramp_x + dx,
2148 y: legend_area.y,
2149 width: 1,
2150 height: 1,
2151 };
2152 Paragraph::new("\u{2588}") .style(ftui::Style::new().fg(color))
2154 .render(cell_rect, frame);
2155 }
2156 }
2157
2158 if legend_area.x + legend_area.width >= lr {
2160 let right_rect = Rect {
2161 x: legend_area.x + legend_area.width - lr,
2162 y: legend_area.y,
2163 width: lr,
2164 height: 1,
2165 };
2166 Paragraph::new(label_right)
2167 .style(ftui::Style::new().fg(cc.muted))
2168 .render(right_rect, frame);
2169 }
2170 }
2171}
2172
2173fn render_heatmap_tabs(
2175 active: HeatmapMetric,
2176 area: Rect,
2177 frame: &mut ftui::Frame,
2178 cc: ChartColors,
2179) {
2180 let metrics = [
2181 HeatmapMetric::ApiTokens,
2182 HeatmapMetric::Messages,
2183 HeatmapMetric::ContentTokens,
2184 HeatmapMetric::ToolCalls,
2185 HeatmapMetric::Coverage,
2186 ];
2187 let mut x = area.x;
2188 for m in &metrics {
2189 let label = m.label();
2190 let is_active = *m == active;
2191 let display = if is_active {
2192 format!(" [{}] ", label)
2193 } else {
2194 format!(" {} ", label)
2195 };
2196 let w = display.len() as u16;
2197 if x + w > area.x + area.width {
2198 break;
2199 }
2200 let style = if is_active {
2201 ftui::Style::new().fg(cc.highlight).bold()
2202 } else {
2203 ftui::Style::new().fg(cc.muted)
2204 };
2205 let tab_rect = Rect {
2206 x,
2207 y: area.y,
2208 width: w,
2209 height: 1,
2210 };
2211 Paragraph::new(display).style(style).render(tab_rect, frame);
2212 x += w;
2213 }
2214}
2215
2216pub fn render_breakdowns(
2218 data: &AnalyticsChartData,
2219 tab: BreakdownTab,
2220 area: Rect,
2221 frame: &mut ftui::Frame,
2222 dark_mode: bool,
2223) {
2224 type BreakdownSeries<'a> = (
2225 &'a [(String, f64)],
2226 &'a [(String, f64)],
2227 fn(usize) -> PackedRgba,
2228 );
2229
2230 let (tokens, messages, color_fn): BreakdownSeries<'_> = match tab {
2232 BreakdownTab::Agent => (&data.agent_tokens, &data.agent_messages, agent_color),
2233 BreakdownTab::Workspace => (
2234 &data.workspace_tokens,
2235 &data.workspace_messages,
2236 breakdown_color,
2237 ),
2238 BreakdownTab::Source => (&data.source_tokens, &data.source_messages, breakdown_color),
2239 BreakdownTab::Model => (&data.model_tokens, &data.model_tokens, model_color),
2240 };
2241
2242 let cc = ChartColors::for_theme(dark_mode);
2243
2244 if tokens.is_empty() {
2245 let msg = format!(
2246 " No {} breakdown data for the current filters.",
2247 tab.label()
2248 );
2249
2250 if area.height >= 12 && area.width >= 40 {
2251 let accent = if dark_mode {
2252 PackedRgba::rgb(90, 180, 255)
2253 } else {
2254 PackedRgba::rgb(20, 100, 200)
2255 };
2256 let primary = if dark_mode {
2257 PackedRgba::rgb(60, 120, 200)
2258 } else {
2259 PackedRgba::rgb(40, 80, 160)
2260 };
2261
2262 let lines = vec![
2263 ftui::text::Line::from(""),
2264 ftui::text::Line::from_spans(vec![
2265 ftui::text::Span::styled(" ██████████ ", ftui::Style::new().fg(accent)),
2266 ftui::text::Span::styled(" ██████████ ", ftui::Style::new().fg(primary)),
2267 ]),
2268 ftui::text::Line::from_spans(vec![
2269 ftui::text::Span::styled(" ████████████ ", ftui::Style::new().fg(accent)),
2270 ftui::text::Span::styled(" ██████████████ ", ftui::Style::new().fg(primary)),
2271 ]),
2272 ftui::text::Line::from_spans(vec![
2273 ftui::text::Span::styled(" ████████████████", ftui::Style::new().fg(accent)),
2274 ftui::text::Span::styled(" ████████ ", ftui::Style::new().fg(primary)),
2275 ]),
2276 ftui::text::Line::from_spans(vec![
2277 ftui::text::Span::styled(" ██████ ", ftui::Style::new().fg(accent)),
2278 ftui::text::Span::styled(" ████████████████", ftui::Style::new().fg(primary)),
2279 ]),
2280 ftui::text::Line::from(""),
2281 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2282 msg,
2283 ftui::Style::new().fg(cc.axis).bold(),
2284 )]),
2285 ];
2286 Paragraph::new(ftui::text::Text::from_lines(lines)).render(area, frame);
2287 return;
2288 }
2289
2290 Paragraph::new(msg)
2291 .style(ftui::Style::new().fg(cc.subtle))
2292 .render(area, frame);
2293 return;
2294 }
2295
2296 let layout = Flex::vertical()
2298 .constraints([Constraint::Fixed(1), Constraint::Min(3)])
2299 .split(area);
2300
2301 render_breakdown_tabs(tab, layout[0], frame, cc);
2303
2304 let content = Rect {
2307 x: layout[1].x + 1,
2308 y: layout[1].y,
2309 width: layout[1].width.saturating_sub(1),
2310 height: layout[1].height,
2311 };
2312
2313 let max_items = (content.height as usize).saturating_sub(2).clamp(8, 25);
2316
2317 if matches!(tab, BreakdownTab::Model) {
2319 let groups: Vec<BarGroup<'_>> = tokens
2320 .iter()
2321 .take(max_items)
2322 .map(|(name, val)| BarGroup::new(name, vec![*val]))
2323 .collect();
2324 let colors: Vec<PackedRgba> = (0..groups.len()).map(color_fn).collect();
2325 let chart = BarChart::new(groups)
2326 .direction(BarDirection::Horizontal)
2327 .bar_width(1)
2328 .colors(colors);
2329 chart.render(content, frame);
2330 return;
2331 }
2332
2333 let chunks = Flex::horizontal()
2334 .constraints([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
2335 .split(content);
2336
2337 {
2339 let token_rows: Vec<(String, f64)> = tokens
2340 .iter()
2341 .take(max_items)
2342 .map(|(name, val)| (shorten_label(name, 20), *val))
2343 .collect();
2344 let groups: Vec<BarGroup<'_>> = token_rows
2345 .iter()
2346 .map(|(label, val)| BarGroup::new(label.as_str(), vec![*val]))
2347 .collect();
2348 let colors: Vec<PackedRgba> = (0..groups.len()).map(color_fn).collect();
2349 let chart = BarChart::new(groups)
2350 .direction(BarDirection::Horizontal)
2351 .bar_width(1)
2352 .colors(colors);
2353 chart.render(chunks[0], frame);
2354 }
2355
2356 {
2358 let message_rows: Vec<(String, f64)> = messages
2359 .iter()
2360 .take(max_items)
2361 .map(|(name, val)| (shorten_label(name, 20), *val))
2362 .collect();
2363 let groups: Vec<BarGroup<'_>> = message_rows
2364 .iter()
2365 .map(|(label, val)| BarGroup::new(label.as_str(), vec![*val]))
2366 .collect();
2367 let colors: Vec<PackedRgba> = (0..groups.len()).map(color_fn).collect();
2368 let chart = BarChart::new(groups)
2369 .direction(BarDirection::Horizontal)
2370 .bar_width(1)
2371 .colors(colors);
2372 chart.render(chunks[1], frame);
2373 }
2374}
2375
2376const BREAKDOWN_COLORS: &[PackedRgba] = &[
2378 PackedRgba::rgb(0, 180, 220),
2379 PackedRgba::rgb(220, 160, 0),
2380 PackedRgba::rgb(80, 200, 120),
2381 PackedRgba::rgb(200, 80, 180),
2382 PackedRgba::rgb(120, 200, 255),
2383 PackedRgba::rgb(255, 140, 80),
2384 PackedRgba::rgb(160, 120, 255),
2385 PackedRgba::rgb(255, 200, 120),
2386];
2387
2388fn breakdown_color(idx: usize) -> PackedRgba {
2389 BREAKDOWN_COLORS[idx % BREAKDOWN_COLORS.len()]
2390}
2391
2392fn model_color(idx: usize) -> PackedRgba {
2393 const MODEL_COLORS: &[PackedRgba] = &[
2394 PackedRgba::rgb(0, 180, 220),
2395 PackedRgba::rgb(220, 120, 0),
2396 PackedRgba::rgb(80, 200, 80),
2397 PackedRgba::rgb(200, 60, 180),
2398 PackedRgba::rgb(255, 200, 60),
2399 PackedRgba::rgb(120, 120, 255),
2400 ];
2401 MODEL_COLORS[idx % MODEL_COLORS.len()]
2402}
2403
2404fn truncate_with_ellipsis(input: &str, max_cols: usize) -> String {
2405 if max_cols == 0 {
2406 return String::new();
2407 }
2408 if display_width(input) <= max_cols {
2409 return input.to_string();
2410 }
2411 if max_cols == 1 {
2412 return "\u{2026}".to_string();
2413 }
2414 let budget = max_cols - 1;
2415 let mut out = String::new();
2416 let mut w = 0;
2417 for ch in input.chars() {
2418 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2419 if w + cw > budget {
2420 break;
2421 }
2422 out.push(ch);
2423 w += cw;
2424 }
2425 out.push('\u{2026}');
2426 out
2427}
2428
2429fn breakdown_tabs_line(active: BreakdownTab, width: usize) -> String {
2430 let mut text = String::with_capacity(96);
2431 text.push(' ');
2432 for tab in BreakdownTab::all() {
2433 if *tab == active {
2434 text.push_str(&format!("[{}]", tab.label()));
2435 } else {
2436 text.push_str(&format!(" {} ", tab.label()));
2437 }
2438 text.push(' ');
2439 }
2440 text.push_str(" (Tab/Shift+Tab to switch)");
2441 truncate_with_ellipsis(&text, width)
2442}
2443
2444fn render_breakdown_tabs(
2446 active: BreakdownTab,
2447 area: Rect,
2448 frame: &mut ftui::Frame,
2449 cc: ChartColors,
2450) {
2451 let text = breakdown_tabs_line(active, area.width as usize);
2452 let style = ftui::Style::new().fg(cc.axis).bold();
2453 Paragraph::new(text).style(style).render(area, frame);
2454}
2455
2456fn shorten_label(s: &str, max_cols: usize) -> String {
2458 if max_cols == 0 {
2459 return String::new();
2460 }
2461 if display_width(s) <= max_cols {
2462 return s.to_string();
2463 }
2464 if s.contains('/') {
2465 let last = s.rsplit('/').next().unwrap_or(s);
2466 if display_width(last) <= max_cols {
2467 return last.to_string();
2468 }
2469 }
2470 if max_cols == 1 {
2471 return "\u{2026}".to_string();
2472 }
2473 let budget = max_cols - 1;
2475 let mut truncated = String::new();
2476 let mut w = 0;
2477 for ch in s.chars() {
2478 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2479 if w + cw > budget {
2480 break;
2481 }
2482 truncated.push(ch);
2483 w += cw;
2484 }
2485 truncated.push('\u{2026}');
2486 truncated
2487}
2488
2489pub fn tools_row_count(data: &AnalyticsChartData) -> usize {
2491 let max_visible = 20;
2492 data.tool_rows.len().min(max_visible)
2493}
2494
2495pub fn coverage_row_count(data: &AnalyticsChartData) -> usize {
2497 data.agent_tokens.len().min(10)
2498}
2499
2500pub fn render_tools(
2502 data: &AnalyticsChartData,
2503 area: Rect,
2504 frame: &mut ftui::Frame,
2505 dark_mode: bool,
2506) {
2507 let cc = ChartColors::for_theme(dark_mode);
2508
2509 if data.tool_rows.is_empty() {
2510 if area.height >= 12 && area.width >= 40 {
2511 let accent = if dark_mode {
2512 PackedRgba::rgb(90, 180, 255)
2513 } else {
2514 PackedRgba::rgb(20, 100, 200)
2515 };
2516 let primary = if dark_mode {
2517 PackedRgba::rgb(60, 120, 200)
2518 } else {
2519 PackedRgba::rgb(40, 80, 160)
2520 };
2521
2522 let lines = vec![
2523 ftui::text::Line::from(""),
2524 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2525 " Agent Calls Msgs Tokens Trend ",
2526 ftui::Style::new().fg(cc.muted),
2527 )]),
2528 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2529 " ██████████ ██ ██ ██ ███ ",
2530 ftui::Style::new().fg(primary),
2531 )]),
2532 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2533 " ████████████ ██ ██ ██ ███ ",
2534 ftui::Style::new().fg(accent),
2535 )]),
2536 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2537 " ██████ ██ ██ ██ ███ ",
2538 ftui::Style::new().fg(primary),
2539 )]),
2540 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2541 " ████████ ██ ██ ██ ███ ",
2542 ftui::Style::new().fg(accent),
2543 )]),
2544 ftui::text::Line::from(""),
2545 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
2546 " No tool usage data available for the current filters.",
2547 ftui::Style::new().fg(cc.axis).bold(),
2548 )]),
2549 ];
2550 Paragraph::new(ftui::text::Text::from_lines(lines)).render(area, frame);
2551 return;
2552 }
2553
2554 Paragraph::new(" No tool usage data available for the current filters.")
2555 .style(ftui::Style::new().fg(cc.subtle))
2556 .render(area, frame);
2557 return;
2558 }
2559
2560 let has_sparkline = !data.daily_tool_calls.is_empty();
2562 let constraints = if has_sparkline {
2563 vec![
2564 Constraint::Fixed(1),
2565 Constraint::Min(3),
2566 Constraint::Fixed(3),
2567 Constraint::Fixed(1),
2568 ]
2569 } else {
2570 vec![
2571 Constraint::Fixed(1),
2572 Constraint::Min(3),
2573 Constraint::Fixed(1),
2574 ]
2575 };
2576 let chunks = Flex::vertical().constraints(constraints).split(area);
2577
2578 let header_style = ftui::Style::new().fg(cc.axis).bold();
2580 let header = tools_header_line(chunks[0].width as usize);
2581 Paragraph::new(header)
2582 .style(header_style)
2583 .render(chunks[0], frame);
2584
2585 let table_area = chunks[1];
2587 let max_rows = (table_area.height as usize).min(tools_row_count(data));
2588 let total_calls = data.total_tool_calls.max(1) as f64;
2589
2590 for (i, row) in data.tool_rows.iter().take(max_rows).enumerate() {
2591 if i >= table_area.height as usize {
2592 break;
2593 }
2594 let row_rect = Rect {
2595 x: table_area.x,
2596 y: table_area.y + i as u16,
2597 width: table_area.width,
2598 height: 1,
2599 };
2600 let pct_share = (row.tool_call_count as f64 / total_calls) * 100.0;
2601 let line = tools_row_line(row, pct_share, row_rect.width as usize);
2602 let color = agent_color(i);
2603 Paragraph::new(line)
2604 .style(ftui::Style::new().fg(color))
2605 .render(row_rect, frame);
2606 }
2607
2608 if has_sparkline {
2610 let spark_area = chunks[2];
2611 let values: Vec<f64> = data.daily_tool_calls.iter().map(|(_, v)| *v).collect();
2612 let sparkline = Sparkline::new(&values)
2613 .gradient(PackedRgba::rgb(60, 60, 120), PackedRgba::rgb(100, 200, 255));
2614 sparkline.render(spark_area, frame);
2615 }
2616
2617 let summary_idx = if has_sparkline { 3 } else { 2 };
2619 let summary = truncate_with_ellipsis(
2620 &format!(
2621 " {} agents \u{00b7} {} total calls \u{00b7} {} API tokens",
2622 data.tool_rows.len(),
2623 format_compact(data.total_tool_calls),
2624 format_compact(
2625 data.tool_rows
2626 .iter()
2627 .map(|r| r.api_tokens_total)
2628 .sum::<i64>()
2629 ),
2630 ),
2631 chunks[summary_idx].width as usize,
2632 );
2633 Paragraph::new(summary)
2634 .style(ftui::Style::new().fg(cc.muted))
2635 .render(chunks[summary_idx], frame);
2636}
2637
2638fn tools_header_line(width: usize) -> String {
2640 if width == 0 {
2641 return String::new();
2642 }
2643
2644 let w = width;
2645
2646 if width < 56 {
2647 let name_w: usize = 10;
2648 let label = "Agent";
2649 let current_w = display_width(label);
2650 let pad_w = name_w.saturating_sub(current_w);
2651 let pad = " ".repeat(pad_w);
2652
2653 let compact = format!(
2654 " {}{} {:>5} {:>5} {:>8} {:>5}",
2655 label, pad, "Calls", "Msgs", "Tokens", "Share"
2656 );
2657 return truncate_with_ellipsis(&compact, width);
2658 }
2659
2660 let name_w = (w * 28 / 100).clamp(8, 24);
2661 let label = "Agent";
2662 let current_w = display_width(label);
2663 let pad_w = name_w.saturating_sub(current_w);
2664 let pad = " ".repeat(pad_w);
2665
2666 let line = format!(
2667 " {}{} {:>8} {:>8} {:>10} {:>8} {:>6}",
2668 label, pad, "Calls", "Msgs", "API Tok", "Calls/1K", "Share",
2669 );
2670 truncate_with_ellipsis(&line, width)
2671}
2672
2673fn tools_row_line(row: &crate::analytics::ToolRow, pct_share: f64, width: usize) -> String {
2675 if width == 0 {
2676 return String::new();
2677 }
2678 let per_1k = row
2679 .tool_calls_per_1k_api_tokens
2680 .map(|v| format!("{v:.2}"))
2681 .unwrap_or_else(|| "\u{2014}".to_string());
2682
2683 if width < 56 {
2684 let name_w: usize = 10;
2685 let truncated_name = shorten_label(&row.key, name_w);
2686 let current_w = display_width(&truncated_name);
2687 let pad_w = name_w.saturating_sub(current_w);
2688 let pad = " ".repeat(pad_w);
2689
2690 let line = format!(
2691 " {}{} {:>5} {:>5} {:>8} {:>4.0}%",
2692 truncated_name,
2693 pad,
2694 format_compact(row.tool_call_count),
2695 format_compact(row.message_count),
2696 format_compact(row.api_tokens_total),
2697 pct_share,
2698 );
2699 return truncate_with_ellipsis(&line, width);
2700 }
2701
2702 let w = width;
2703 let name_w = (w * 28 / 100).clamp(8, 24);
2704 let truncated_name = shorten_label(&row.key, name_w);
2705 let current_w = display_width(&truncated_name);
2706 let pad_w = name_w.saturating_sub(current_w);
2707 let pad = " ".repeat(pad_w);
2708
2709 let line = format!(
2710 " {}{} {:>8} {:>8} {:>10} {:>8} {:>5.1}%",
2711 truncated_name,
2712 pad,
2713 format_number(row.tool_call_count),
2714 format_number(row.message_count),
2715 format_compact(row.api_tokens_total),
2716 per_1k,
2717 pct_share,
2718 );
2719 truncate_with_ellipsis(&line, width)
2720}
2721
2722pub fn plans_rows(data: &AnalyticsChartData) -> usize {
2727 data.agent_plan_messages.len().min(15)
2728}
2729
2730fn render_plans(
2732 data: &AnalyticsChartData,
2733 selection: usize,
2734 area: Rect,
2735 frame: &mut ftui::Frame,
2736 dark_mode: bool,
2737) {
2738 if area.height < 3 || area.width < 20 {
2739 return;
2740 }
2741 let cc = ChartColors::for_theme(dark_mode);
2742
2743 let total_plan = data.total_plan_messages;
2744 let total_msgs = data.total_messages;
2745 let plan_pct = if total_msgs > 0 {
2746 (total_plan as f64 / total_msgs as f64) * 100.0
2747 } else {
2748 0.0
2749 };
2750
2751 let header = truncate_with_ellipsis(
2753 &format!(
2754 " Plans: {} plan msgs / {} total ({:.1}%) | Up/Down=select Enter=drilldown",
2755 format_compact(total_plan),
2756 format_compact(total_msgs),
2757 plan_pct,
2758 ),
2759 area.width as usize,
2760 );
2761 Paragraph::new(header)
2762 .style(ftui::Style::new().fg(cc.emphasis))
2763 .render(
2764 Rect {
2765 x: area.x,
2766 y: area.y,
2767 width: area.width,
2768 height: 1,
2769 },
2770 frame,
2771 );
2772
2773 let max_val = data
2775 .agent_plan_messages
2776 .first()
2777 .map(|(_, v)| *v)
2778 .unwrap_or(1.0)
2779 .max(1.0);
2780
2781 for (i, (agent, count)) in data.agent_plan_messages.iter().enumerate().take(15) {
2782 let y = area.y + 1 + i as u16;
2783 if y >= area.y + area.height {
2784 break;
2785 }
2786 let bar_width = ((count / max_val) * (area.width as f64 * 0.5).max(1.0)) as u16;
2787 let value = format_compact(*count as i64);
2788 let value_w = display_width(&value);
2789 let agent_w = area.width.saturating_sub(value_w as u16 + 3).max(4) as usize;
2790 let label = truncate_with_ellipsis(
2791 &format!(
2792 " {:<agent_w$} {:>value_w$}",
2793 shorten_label(agent, agent_w),
2794 value,
2795 agent_w = agent_w,
2796 value_w = value_w.max(1),
2797 ),
2798 area.width as usize,
2799 );
2800 let fg = if i == selection {
2801 cc.highlight
2802 } else {
2803 cc.highlight_dim
2804 };
2805 let row_area = Rect {
2806 x: area.x,
2807 y,
2808 width: area.width,
2809 height: 1,
2810 };
2811 let bar_area = Rect {
2813 x: area.x,
2814 y,
2815 width: bar_width.min(area.width),
2816 height: 1,
2817 };
2818 let bar_bg = if dark_mode {
2819 PackedRgba::rgb(80, 60, 0)
2820 } else {
2821 PackedRgba::rgb(255, 235, 180)
2822 };
2823 Paragraph::new("")
2824 .style(ftui::Style::new().bg(bar_bg))
2825 .render(bar_area, frame);
2826 Paragraph::new(label)
2828 .style(ftui::Style::new().fg(fg))
2829 .render(row_area, frame);
2830 }
2831}
2832
2833pub fn render_coverage(
2835 data: &AnalyticsChartData,
2836 area: Rect,
2837 frame: &mut ftui::Frame,
2838 dark_mode: bool,
2839) {
2840 let cc = ChartColors::for_theme(dark_mode);
2841
2842 let agent_row_count = data.agent_tokens.len().min(10);
2844 let table_height = if agent_row_count > 0 {
2845 (agent_row_count + 1) as u16 } else {
2847 0
2848 };
2849
2850 let chunks = Flex::vertical()
2851 .constraints([
2852 Constraint::Fixed(2), Constraint::Fixed(table_height), Constraint::Min(3), ])
2856 .split(area);
2857
2858 let bar_width = area.width.saturating_sub(6) as usize;
2860 let api_filled = (data.coverage_pct / 100.0 * bar_width as f64).round() as usize;
2861 let api_empty = bar_width.saturating_sub(api_filled);
2862 let line1 = truncate_with_ellipsis(
2863 &format!(
2864 " API Token Coverage: {:.1}% [{}{}]",
2865 data.coverage_pct,
2866 "\u{2588}".repeat(api_filled),
2867 "\u{2591}".repeat(api_empty),
2868 ),
2869 chunks[0].width as usize,
2870 );
2871 let line2 = truncate_with_ellipsis(
2872 &format!(
2873 " {} agents \u{2502} {} total API tokens",
2874 data.agent_count,
2875 format_compact(data.total_api_tokens),
2876 ),
2877 chunks[0].width as usize,
2878 );
2879 let cov_color = coverage_color(data.coverage_pct);
2880 Paragraph::new(line1)
2881 .style(ftui::Style::new().fg(cov_color))
2882 .render(chunks[0], frame);
2883 if chunks[0].height > 1 {
2884 let line2_area = Rect {
2885 x: chunks[0].x,
2886 y: chunks[0].y + 1,
2887 width: chunks[0].width,
2888 height: 1,
2889 };
2890 Paragraph::new(line2)
2891 .style(ftui::Style::new().fg(cc.muted))
2892 .render(line2_area, frame);
2893 }
2894
2895 if agent_row_count > 0 && chunks[1].height > 0 {
2897 let w = chunks[1].width as usize;
2898 let header = if w < 48 {
2900 let lbl = "Agent";
2901 let pad = " ".repeat(12_usize.saturating_sub(display_width(lbl)));
2902 format!(" {}{} {:>8} {:>6}", lbl, pad, "Tokens", "Msgs")
2903 } else {
2904 let lbl = "Agent";
2905 let pad = " ".repeat(16_usize.saturating_sub(display_width(lbl)));
2906 format!(
2907 " {}{} {:>12} {:>10} {:>8}",
2908 lbl, pad, "API Tokens", "Messages", "Data"
2909 )
2910 };
2911 let header_trunc = coverage_truncate(&header, w);
2912 let header_area = Rect {
2913 x: chunks[1].x,
2914 y: chunks[1].y,
2915 width: chunks[1].width,
2916 height: 1,
2917 };
2918 Paragraph::new(header_trunc)
2919 .style(ftui::Style::new().fg(cc.emphasis).bold())
2920 .render(header_area, frame);
2921
2922 for (i, (agent, tokens)) in data.agent_tokens.iter().take(10).enumerate() {
2924 let row_y = chunks[1].y + 1 + i as u16;
2925 if row_y >= chunks[1].y + chunks[1].height {
2926 break;
2927 }
2928 let msgs = data
2929 .agent_messages
2930 .iter()
2931 .find(|(a, _)| a == agent)
2932 .map(|(_, v)| *v)
2933 .unwrap_or(0.0);
2934 let data_indicator = if *tokens > 0.0 {
2936 "\u{2713} API"
2937 } else {
2938 "~ est"
2939 };
2940 let indicator_color = if *tokens > 0.0 {
2941 PackedRgba::rgb(80, 200, 80)
2942 } else {
2943 PackedRgba::rgb(255, 200, 0)
2944 };
2945 let row_text = if w < 48 {
2946 let name_w = 12;
2947 let t_name = coverage_truncate(agent, name_w);
2948 let pad = " ".repeat(name_w.saturating_sub(display_width(&t_name)));
2949 format!(
2950 " {}{} {:>8} {:>6}",
2951 t_name,
2952 pad,
2953 format_compact(*tokens as i64),
2954 format_compact(msgs as i64),
2955 )
2956 } else {
2957 let name_w = 16;
2958 let t_name = coverage_truncate(agent, name_w);
2959 let pad = " ".repeat(name_w.saturating_sub(display_width(&t_name)));
2960 format!(
2961 " {}{} {:>12} {:>10} {:>8}",
2962 t_name,
2963 pad,
2964 format_compact(*tokens as i64),
2965 format_compact(msgs as i64),
2966 "",
2967 )
2968 };
2969 let row_trunc = coverage_truncate(&row_text, w);
2970 let row_area = Rect {
2971 x: chunks[1].x,
2972 y: row_y,
2973 width: chunks[1].width,
2974 height: 1,
2975 };
2976 Paragraph::new(row_trunc)
2977 .style(ftui::Style::new().fg(agent_color(i)))
2978 .render(row_area, frame);
2979 let indicator_len = display_width(data_indicator) as u16;
2981 if w >= 48 && chunks[1].width > indicator_len + 1 {
2982 let ind_area = Rect {
2983 x: chunks[1].x + chunks[1].width - indicator_len - 1,
2984 y: row_y,
2985 width: indicator_len + 1,
2986 height: 1,
2987 };
2988 let ind_text = format!(
2989 "{:>width$}",
2990 data_indicator,
2991 width = (indicator_len + 1) as usize
2992 );
2993 Paragraph::new(ind_text)
2994 .style(ftui::Style::new().fg(indicator_color))
2995 .render(ind_area, frame);
2996 }
2997 }
2998 }
2999
3000 if !data.daily_tokens.is_empty() {
3002 let label = " Daily API Tokens";
3003 if chunks[2].height > 0 {
3004 let label_text = truncate_with_ellipsis(label, chunks[2].width as usize);
3005 let label_area = Rect {
3006 x: chunks[2].x,
3007 y: chunks[2].y,
3008 width: chunks[2].width.min(display_width(&label_text) as u16),
3009 height: 1,
3010 };
3011 Paragraph::new(label_text)
3012 .style(ftui::Style::new().fg(cc.muted))
3013 .render(label_area, frame);
3014 }
3015
3016 let spark_area = if chunks[2].height > 1 {
3017 Rect {
3018 x: chunks[2].x,
3019 y: chunks[2].y + 1,
3020 width: chunks[2].width,
3021 height: chunks[2].height - 1,
3022 }
3023 } else {
3024 chunks[2]
3025 };
3026 let values: Vec<f64> = data.daily_tokens.iter().map(|(_, v)| *v).collect();
3027 let sparkline = Sparkline::new(&values)
3028 .gradient(PackedRgba::rgb(60, 60, 120), PackedRgba::rgb(80, 200, 80));
3029 sparkline.render(spark_area, frame);
3030 } else {
3031 if chunks[2].height >= 8 && chunks[2].width >= 40 {
3032 let accent = if dark_mode {
3033 PackedRgba::rgb(90, 180, 255)
3034 } else {
3035 PackedRgba::rgb(20, 100, 200)
3036 };
3037 let primary = if dark_mode {
3038 PackedRgba::rgb(60, 120, 200)
3039 } else {
3040 PackedRgba::rgb(40, 80, 160)
3041 };
3042
3043 let lines = vec![
3044 ftui::text::Line::from(""),
3045 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
3046 " ▂▂▃▄▅▆▇██████████████▇▆▅▄▃▂▂ ",
3047 ftui::Style::new().fg(accent),
3048 )]),
3049 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
3050 " ████████████████████████████ ",
3051 ftui::Style::new().fg(primary),
3052 )]),
3053 ftui::text::Line::from(""),
3054 ftui::text::Line::from_spans(vec![ftui::text::Span::styled(
3055 " No daily data for sparkline",
3056 ftui::Style::new().fg(cc.axis).bold(),
3057 )]),
3058 ];
3059 Paragraph::new(ftui::text::Text::from_lines(lines)).render(chunks[2], frame);
3060 return;
3061 }
3062
3063 Paragraph::new(" No daily data for sparkline")
3064 .style(ftui::Style::new().fg(cc.subtle))
3065 .render(chunks[2], frame);
3066 }
3067}
3068
3069fn coverage_color(pct: f64) -> PackedRgba {
3070 if pct >= 80.0 {
3071 PackedRgba::rgb(80, 200, 80)
3072 } else if pct >= 50.0 {
3073 PackedRgba::rgb(255, 200, 0)
3074 } else {
3075 PackedRgba::rgb(255, 80, 80)
3076 }
3077}
3078
3079fn coverage_truncate(s: &str, max_len: usize) -> String {
3080 truncate_with_ellipsis(s, max_len)
3081}
3082
3083fn display_width(input: &str) -> usize {
3084 unicode_width::UnicodeWidthStr::width(input)
3085}
3086
3087pub struct ExplorerState {
3089 pub metric: ExplorerMetric,
3090 pub overlay: ExplorerOverlay,
3091 pub group_by: crate::analytics::GroupBy,
3092 pub zoom: super::app::ExplorerZoom,
3093}
3094
3095#[allow(clippy::too_many_arguments)]
3099pub fn render_analytics_content(
3100 view: AnalyticsView,
3101 data: &AnalyticsChartData,
3102 explorer: &ExplorerState,
3103 breakdown_tab: BreakdownTab,
3104 heatmap_metric: HeatmapMetric,
3105 selection: usize,
3106 area: Rect,
3107 frame: &mut ftui::Frame,
3108 dark_mode: bool,
3109) {
3110 match view {
3111 AnalyticsView::Dashboard => render_dashboard(data, area, frame, dark_mode),
3112 AnalyticsView::Explorer => render_explorer(data, explorer, area, frame, dark_mode),
3113 AnalyticsView::Heatmap => {
3114 render_heatmap(data, heatmap_metric, selection, area, frame, dark_mode)
3115 }
3116 AnalyticsView::Breakdowns => {
3117 render_breakdowns(data, breakdown_tab, area, frame, dark_mode);
3118 let row_count = breakdown_rows(data, breakdown_tab);
3119 let content_area = if area.height > 1 {
3121 Rect {
3122 x: area.x,
3123 y: area.y + 1,
3124 width: area.width,
3125 height: area.height - 1,
3126 }
3127 } else {
3128 area
3129 };
3130 render_selection_indicator(
3131 selection,
3132 row_count,
3133 content_area,
3134 frame,
3135 !matches!(breakdown_tab, BreakdownTab::Model),
3136 dark_mode,
3137 );
3138 }
3139 AnalyticsView::Tools => {
3140 render_tools(data, area, frame, dark_mode);
3141 let tools_content = if area.height > 1 {
3143 Rect {
3144 x: area.x,
3145 y: area.y + 1,
3146 width: area.width,
3147 height: area.height - 1,
3148 }
3149 } else {
3150 area
3151 };
3152 render_selection_indicator(
3153 selection,
3154 tools_row_count(data),
3155 tools_content,
3156 frame,
3157 false,
3158 dark_mode,
3159 );
3160 }
3161 AnalyticsView::Plans => {
3162 render_plans(data, selection, area, frame, dark_mode);
3163 }
3164 AnalyticsView::Coverage => {
3165 render_coverage(data, area, frame, dark_mode);
3166 let row_count = coverage_row_count(data);
3168 if row_count > 0 && area.height > 3 {
3169 let cov_content = Rect {
3170 x: area.x,
3171 y: area.y + 3, width: area.width,
3173 height: area.height.saturating_sub(3),
3174 };
3175 render_selection_indicator(
3176 selection,
3177 row_count,
3178 cov_content,
3179 frame,
3180 false,
3181 dark_mode,
3182 );
3183 }
3184 }
3185 }
3186}
3187
3188pub fn breakdown_rows(data: &AnalyticsChartData, tab: BreakdownTab) -> usize {
3190 match tab {
3191 BreakdownTab::Agent => data.agent_tokens.len().min(8),
3192 BreakdownTab::Workspace => data.workspace_tokens.len().min(8),
3193 BreakdownTab::Source => data.source_tokens.len().min(8),
3194 BreakdownTab::Model => data.model_tokens.len().min(10),
3195 }
3196}
3197
3198fn render_selection_indicator(
3203 selection: usize,
3204 max_rows: usize,
3205 area: Rect,
3206 frame: &mut ftui::Frame,
3207 half_width: bool,
3208 dark_mode: bool,
3209) {
3210 if max_rows == 0 || selection >= max_rows {
3211 return;
3212 }
3213 let target_area = if half_width {
3214 let chunks = Flex::horizontal()
3215 .constraints([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
3216 .split(area);
3217 chunks[0]
3218 } else {
3219 area
3220 };
3221 if target_area.height <= selection as u16 {
3222 return;
3223 }
3224 let sel_y = target_area.y + selection as u16;
3225 let indicator = Rect {
3226 x: target_area.x,
3227 y: sel_y,
3228 width: 1,
3229 height: 1,
3230 };
3231 let cc = ChartColors::for_theme(dark_mode);
3232 Paragraph::new("\u{25b6}")
3233 .style(ftui::Style::new().fg(cc.highlight).bold())
3234 .render(indicator, frame);
3235}
3236
3237fn format_number(n: i64) -> String {
3243 let (prefix, abs_str) = if n < 0 {
3244 ("-", n.unsigned_abs().to_string())
3245 } else {
3246 ("", n.to_string())
3247 };
3248 let mut result = String::with_capacity(abs_str.len() + abs_str.len() / 3 + prefix.len());
3249 for (i, c) in abs_str.chars().rev().enumerate() {
3250 if i > 0 && i % 3 == 0 {
3251 result.push(',');
3252 }
3253 result.push(c);
3254 }
3255 let grouped: String = result.chars().rev().collect();
3256 format!("{prefix}{grouped}")
3257}
3258
3259#[cfg(test)]
3264mod tests {
3265 use super::*;
3266 use frankensqlite::compat::ConnectionExt;
3267 use frankensqlite::params;
3268
3269 #[test]
3270 fn resolve_workspace_filter_ids_supports_paths_and_numeric_ids() {
3271 let conn = frankensqlite::Connection::open(":memory:").unwrap();
3272 conn.execute_batch(
3273 "CREATE TABLE workspaces (
3274 id INTEGER PRIMARY KEY,
3275 path TEXT NOT NULL UNIQUE
3276 );",
3277 )
3278 .unwrap();
3279 conn.execute_compat(
3280 "INSERT INTO workspaces (id, path) VALUES (?1, ?2)",
3281 params![1_i64, "/workspace/one"],
3282 )
3283 .unwrap();
3284 conn.execute_compat(
3285 "INSERT INTO workspaces (id, path) VALUES (?1, ?2)",
3286 params![2_i64, "/workspace/two"],
3287 )
3288 .unwrap();
3289
3290 let mut filters = std::collections::HashSet::new();
3291 filters.insert("/workspace/one".to_string());
3292 filters.insert("2".to_string());
3293 filters.insert("/workspace/missing".to_string());
3294
3295 let ids = resolve_workspace_filter_ids(&conn, &filters);
3296 assert!(ids.contains(&1));
3297 assert!(ids.contains(&2));
3298 assert_eq!(ids.iter().filter(|id| **id == 2).count(), 1);
3299 }
3300
3301 #[test]
3302 fn load_chart_data_applies_workspace_path_filter() {
3303 let tmp = tempfile::TempDir::new().unwrap();
3304 let db_path = tmp.path().join("analytics_filters.db");
3305 let storage = crate::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
3306
3307 let ws_a = storage
3308 .ensure_workspace(std::path::Path::new("/workspace/a"), None)
3309 .unwrap();
3310 let ws_b = storage
3311 .ensure_workspace(std::path::Path::new("/workspace/b"), None)
3312 .unwrap();
3313
3314 let now_ms = std::time::SystemTime::now()
3315 .duration_since(std::time::UNIX_EPOCH)
3316 .unwrap()
3317 .as_millis() as i64;
3318 let conn = storage.raw();
3319 conn.execute_compat(
3320 "INSERT INTO usage_daily (
3321 day_id, agent_slug, workspace_id, source_id,
3322 message_count, tool_call_count, api_tokens_total, last_updated
3323 ) VALUES (?1, 'codex', ?2, 'local', 10, 2, 1000, ?3)",
3324 params![20260220_i64, ws_a, now_ms],
3325 )
3326 .unwrap();
3327 conn.execute_compat(
3328 "INSERT INTO usage_daily (
3329 day_id, agent_slug, workspace_id, source_id,
3330 message_count, tool_call_count, api_tokens_total, last_updated
3331 ) VALUES (?1, 'codex', ?2, 'local', 20, 4, 2000, ?3)",
3332 params![20260220_i64, ws_b, now_ms],
3333 )
3334 .unwrap();
3335
3336 let mut filters = crate::ui::app::AnalyticsFilterState::default();
3337 filters.workspaces.insert("/workspace/a".to_string());
3338
3339 let data = load_chart_data(&storage, &filters, crate::analytics::GroupBy::Day);
3340 assert_eq!(data.total_api_tokens, 1000);
3341 assert_eq!(data.total_messages, 10);
3342 assert_eq!(data.total_tool_calls, 2);
3343 assert_eq!(
3344 data.agent_tokens.first().map(|(_, v)| *v as i64),
3345 Some(1000)
3346 );
3347 }
3348
3349 #[test]
3350 fn format_number_basic() {
3351 assert_eq!(format_number(0), "0");
3352 assert_eq!(format_number(999), "999");
3353 assert_eq!(format_number(1000), "1,000");
3354 assert_eq!(format_number(1234567), "1,234,567");
3355 assert_eq!(format_number(100), "100");
3356 }
3357
3358 #[test]
3359 fn format_compact_suffixes() {
3360 assert_eq!(format_compact(0), "0");
3361 assert_eq!(format_compact(999), "999");
3362 assert_eq!(format_compact(9999), "9,999");
3363 assert_eq!(format_compact(10_000), "10.0K");
3364 assert_eq!(format_compact(1_500_000), "1.5M");
3365 assert_eq!(format_compact(2_300_000_000), "2.3B");
3366 }
3367
3368 #[test]
3369 fn format_explorer_metric_value_is_compact() {
3370 assert_eq!(
3371 format_explorer_metric_value(ExplorerMetric::ApiTokens, 12.3456),
3372 "12"
3373 );
3374 }
3375
3376 #[test]
3377 fn build_explorer_annotation_line_contains_peak_avg_trend() {
3378 let metric_data = vec![
3379 ("2026-02-01".to_string(), 100.0),
3380 ("2026-02-02".to_string(), 300.0),
3381 ("2026-02-03".to_string(), 200.0),
3382 ];
3383 let line = build_explorer_annotation_line(
3384 ExplorerMetric::ApiTokens,
3385 &metric_data,
3386 &["codex".to_string(), "claude_code".to_string()],
3387 );
3388 assert!(line.contains("Peak"));
3389 assert!(line.contains("Avg"));
3390 assert!(line.contains("Trend"));
3391 assert!(line.contains("2026-02-02"));
3392 assert!(line.contains("Top overlay: codex"));
3393 }
3394
3395 #[test]
3396 fn dim_color_scales_channels_down() {
3397 let c = PackedRgba::rgb(200, 100, 50);
3398 let d = dim_color(c, 0.5);
3399 assert_eq!(d.r(), 100);
3400 assert_eq!(d.g(), 50);
3401 assert_eq!(d.b(), 25);
3402 }
3403
3404 #[test]
3405 fn agent_color_cycles() {
3406 let c0 = agent_color(0);
3407 let c14 = agent_color(14);
3408 assert_eq!(c0, c14); }
3410
3411 #[test]
3412 fn default_chart_data_is_empty() {
3413 let data = AnalyticsChartData::default();
3414 assert!(data.agent_tokens.is_empty());
3415 assert!(data.daily_tokens.is_empty());
3416 assert_eq!(data.total_messages, 0);
3417 assert_eq!(data.coverage_pct, 0.0);
3418 }
3419
3420 #[test]
3421 fn render_analytics_content_all_views_no_panic() {
3422 let data = AnalyticsChartData::default();
3424 let _ = &data;
3427 for view in AnalyticsView::all() {
3428 match view {
3430 AnalyticsView::Dashboard
3431 | AnalyticsView::Explorer
3432 | AnalyticsView::Heatmap
3433 | AnalyticsView::Breakdowns
3434 | AnalyticsView::Tools
3435 | AnalyticsView::Plans
3436 | AnalyticsView::Coverage => {}
3437 }
3438 }
3439 }
3440
3441 #[test]
3442 fn weekday_index_known_dates() {
3443 assert_eq!(weekday_index(2026, 2, 7), 5);
3445 assert_eq!(weekday_index(2026, 2, 2), 0);
3447 assert_eq!(weekday_index(2026, 1, 1), 3);
3449 }
3450
3451 #[test]
3452 fn parse_day_label_valid() {
3453 assert_eq!(parse_day_label("2026-02-07"), Some((2026, 2, 7)));
3454 assert_eq!(parse_day_label("2025-12-31"), Some((2025, 12, 31)));
3455 assert_eq!(parse_day_label("invalid"), None);
3456 assert_eq!(parse_day_label("2026-13-01"), Some((2026, 13, 1))); }
3458
3459 #[test]
3460 fn heatmap_series_empty_data() {
3461 let data = AnalyticsChartData::default();
3462 let (series, min, max) = heatmap_series_for_metric(&data, HeatmapMetric::ApiTokens);
3463 assert!(series.is_empty());
3464 assert_eq!(min, 0.0);
3465 assert_eq!(max, 0.0);
3466 }
3467
3468 #[test]
3469 fn heatmap_series_normalizes() {
3470 let data = AnalyticsChartData {
3471 daily_tokens: vec![
3472 ("2026-02-01".to_string(), 100.0),
3473 ("2026-02-02".to_string(), 200.0),
3474 ("2026-02-03".to_string(), 50.0),
3475 ],
3476 ..Default::default()
3477 };
3478 let (series, min, max) = heatmap_series_for_metric(&data, HeatmapMetric::ApiTokens);
3479 assert_eq!(series.len(), 3);
3480 assert_eq!(max, 200.0);
3481 assert_eq!(min, 50.0);
3482 assert!((series[1].1 - 1.0).abs() < 0.001);
3484 assert!((series[2].1 - 0.25).abs() < 0.001);
3486 }
3487
3488 #[test]
3489 fn heatmap_series_coverage_uses_normalized_heatmap_days() {
3490 let data = AnalyticsChartData {
3491 heatmap_days: vec![
3492 ("2026-02-01".to_string(), 0.25),
3493 ("2026-02-02".to_string(), 1.0),
3494 ],
3495 ..Default::default()
3496 };
3497 let (series, min, max) = heatmap_series_for_metric(&data, HeatmapMetric::Coverage);
3498 assert_eq!(series, data.heatmap_days);
3499 assert!((min - 25.0).abs() < 0.001);
3500 assert!((max - 100.0).abs() < 0.001);
3501 }
3502
3503 #[test]
3504 fn format_heatmap_value_coverage_is_percent() {
3505 assert_eq!(format_heatmap_value(72.9, HeatmapMetric::Coverage), "73%");
3506 }
3507
3508 #[test]
3509 fn format_heatmap_value_tokens() {
3510 assert_eq!(
3511 format_heatmap_value(1500000.0, HeatmapMetric::ApiTokens),
3512 "1.5M"
3513 );
3514 assert_eq!(format_heatmap_value(500.0, HeatmapMetric::Messages), "500");
3515 }
3516
3517 #[test]
3518 fn heatmap_metric_cycles() {
3519 let m = HeatmapMetric::default();
3520 assert_eq!(m, HeatmapMetric::ApiTokens);
3521 assert_eq!(m.next(), HeatmapMetric::Messages);
3522 assert_eq!(HeatmapMetric::Coverage.next(), HeatmapMetric::ApiTokens);
3523 assert_eq!(HeatmapMetric::ApiTokens.prev(), HeatmapMetric::Coverage);
3524 }
3525
3526 fn sample_tool_rows() -> Vec<crate::analytics::ToolRow> {
3529 vec![
3530 crate::analytics::ToolRow {
3531 key: "claude_code".to_string(),
3532 tool_call_count: 12000,
3533 message_count: 1200,
3534 api_tokens_total: 45_000_000,
3535 tool_calls_per_1k_api_tokens: Some(0.267),
3536 tool_calls_per_1k_content_tokens: Some(0.5),
3537 },
3538 crate::analytics::ToolRow {
3539 key: "codex".to_string(),
3540 tool_call_count: 8000,
3541 message_count: 800,
3542 api_tokens_total: 23_000_000,
3543 tool_calls_per_1k_api_tokens: Some(0.348),
3544 tool_calls_per_1k_content_tokens: None,
3545 },
3546 crate::analytics::ToolRow {
3547 key: "aider".to_string(),
3548 tool_call_count: 2000,
3549 message_count: 400,
3550 api_tokens_total: 12_000_000,
3551 tool_calls_per_1k_api_tokens: Some(0.167),
3552 tool_calls_per_1k_content_tokens: None,
3553 },
3554 ]
3555 }
3556
3557 #[test]
3558 fn tools_row_count_empty() {
3559 let data = AnalyticsChartData::default();
3560 assert_eq!(tools_row_count(&data), 0);
3561 }
3562
3563 #[test]
3564 fn tools_row_count_with_data() {
3565 let data = AnalyticsChartData {
3566 tool_rows: sample_tool_rows(),
3567 ..Default::default()
3568 };
3569 assert_eq!(tools_row_count(&data), 3);
3570 }
3571
3572 #[test]
3573 fn tools_row_count_capped_at_20() {
3574 let rows: Vec<crate::analytics::ToolRow> = (0..30)
3575 .map(|i| crate::analytics::ToolRow {
3576 key: format!("agent_{i}"),
3577 tool_call_count: 100 - i,
3578 message_count: 10,
3579 api_tokens_total: 1000,
3580 tool_calls_per_1k_api_tokens: Some(0.1),
3581 tool_calls_per_1k_content_tokens: None,
3582 })
3583 .collect();
3584 let data = AnalyticsChartData {
3585 tool_rows: rows,
3586 ..Default::default()
3587 };
3588 assert_eq!(tools_row_count(&data), 20);
3589 }
3590
3591 #[test]
3592 fn tools_header_line_contains_columns() {
3593 let header = tools_header_line(100);
3594 assert!(header.contains("Agent"));
3595 assert!(header.contains("Calls"));
3596 assert!(header.contains("Msgs"));
3597 assert!(header.contains("API"));
3598 assert!(header.contains("Calls/1K"));
3599 assert!(header.contains("Share"));
3600 }
3601
3602 #[test]
3603 fn tools_header_line_respects_requested_width() {
3604 let header = tools_header_line(24);
3605 assert!(
3606 header.chars().count() <= 24,
3607 "header should be truncated to available width"
3608 );
3609 }
3610
3611 #[test]
3612 fn tools_row_line_formats_numbers() {
3613 let row = &sample_tool_rows()[0];
3614 let line = tools_row_line(row, 54.5, 100);
3615 assert!(line.contains("claude_code"));
3616 assert!(line.contains("12,000"));
3617 assert!(line.contains("1,200"));
3618 assert!(line.contains("45.0M"));
3619 assert!(line.contains("0.27"));
3620 assert!(line.contains("54.5%"));
3621 }
3622
3623 #[test]
3624 fn tools_row_line_handles_no_per_1k() {
3625 let row = crate::analytics::ToolRow {
3626 key: "test".to_string(),
3627 tool_call_count: 100,
3628 message_count: 10,
3629 api_tokens_total: 0,
3630 tool_calls_per_1k_api_tokens: None,
3631 tool_calls_per_1k_content_tokens: None,
3632 };
3633 let line = tools_row_line(&row, 1.0, 80);
3634 assert!(line.contains("\u{2014}")); }
3636
3637 #[test]
3638 fn tools_row_line_respects_requested_width() {
3639 let row = &sample_tool_rows()[0];
3640 let line = tools_row_line(row, 33.3, 28);
3641 assert!(
3642 line.chars().count() <= 28,
3643 "row should be truncated to available width"
3644 );
3645 }
3646
3647 #[test]
3648 fn breakdown_tabs_line_respects_requested_width() {
3649 let line = breakdown_tabs_line(BreakdownTab::Agent, 36);
3650 assert!(
3651 line.chars().count() <= 36,
3652 "tab line should be truncated on narrow terminals"
3653 );
3654 }
3655
3656 #[test]
3657 fn shorten_label_handles_unicode_boundaries() {
3658 let label = "agent/\u{1F9EA}unicode-project";
3659 let shortened = shorten_label(label, 7);
3660 assert!(
3661 shortened.chars().count() <= 7,
3662 "unicode labels must truncate safely"
3663 );
3664 }
3665
3666 #[test]
3669 fn coverage_row_count_empty() {
3670 let data = AnalyticsChartData::default();
3671 assert_eq!(coverage_row_count(&data), 0);
3672 }
3673
3674 #[test]
3675 fn coverage_row_count_with_agents() {
3676 let data = AnalyticsChartData {
3677 agent_tokens: vec![
3678 ("claude_code".to_string(), 1000.0),
3679 ("codex".to_string(), 500.0),
3680 ],
3681 ..Default::default()
3682 };
3683 assert_eq!(coverage_row_count(&data), 2);
3684 }
3685
3686 #[test]
3687 fn coverage_row_count_capped_at_10() {
3688 let agents: Vec<(String, f64)> = (0..15)
3689 .map(|i| (format!("agent_{i}"), 100.0 * (15 - i) as f64))
3690 .collect();
3691 let data = AnalyticsChartData {
3692 agent_tokens: agents,
3693 ..Default::default()
3694 };
3695 assert_eq!(coverage_row_count(&data), 10);
3696 }
3697
3698 #[test]
3699 fn coverage_color_thresholds() {
3700 let green = coverage_color(80.0);
3701 let yellow = coverage_color(50.0);
3702 let red = coverage_color(30.0);
3703 assert_eq!(green, PackedRgba::rgb(80, 200, 80));
3705 assert_eq!(yellow, PackedRgba::rgb(255, 200, 0));
3707 assert_eq!(red, PackedRgba::rgb(255, 80, 80));
3709 }
3710}