Skip to main content

coding_agent_search/ui/
analytics_charts.rs

1//! Analytics chart rendering for the ftui analytics views.
2//!
3//! Provides [`AnalyticsChartData`] (pre-computed chart data) and rendering
4//! functions that turn analytics query results into terminal-native
5//! visualizations using ftui-extras charts and canvas widgets.
6//!
7//! Chart data is loaded via `load_chart_data(db, filters, group_by)` — a single
8//! DB query path that all 8 analytics views share. Views consume
9//! pre-computed data during `view()` without further DB access.
10//! The Explorer view layer adds dimension overlays via `build_dimension_overlay()`
11//! for proportional breakdowns by agent/workspace/source.
12
13use 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
25// ---------------------------------------------------------------------------
26// Agent accent colors (consistent across all chart views)
27// ---------------------------------------------------------------------------
28
29/// Fixed color palette for up to 14 agents. Colors cycle for overflow.
30const AGENT_COLORS: &[PackedRgba] = &[
31    PackedRgba::rgb(0, 150, 255),   // claude_code — cyan
32    PackedRgba::rgb(255, 100, 0),   // codex — orange
33    PackedRgba::rgb(0, 200, 100),   // gemini — green
34    PackedRgba::rgb(200, 50, 200),  // cursor — magenta
35    PackedRgba::rgb(255, 200, 0),   // chatgpt — gold
36    PackedRgba::rgb(100, 200, 255), // aider — sky
37    PackedRgba::rgb(255, 80, 80),   // pi_agent — red
38    PackedRgba::rgb(150, 255, 150), // cline — lime
39    PackedRgba::rgb(180, 130, 255), // opencode — lavender
40    PackedRgba::rgb(255, 160, 200), // amp — pink
41    PackedRgba::rgb(200, 200, 100), // factory — olive
42    PackedRgba::rgb(100, 255, 200), // clawdbot — mint
43    PackedRgba::rgb(255, 220, 150), // vibe — peach
44    PackedRgba::rgb(150, 150, 255), // openclaw — periwinkle
45];
46
47fn agent_color(idx: usize) -> PackedRgba {
48    AGENT_COLORS[idx % AGENT_COLORS.len()]
49}
50
51// ---------------------------------------------------------------------------
52// Theme-adaptive structural colors for chart chrome
53// ---------------------------------------------------------------------------
54
55/// Structural colors for chart axes, labels, gridlines, and text that adapt
56/// to dark vs. light backgrounds. All chart renderers should use these
57/// instead of hardcoding gray tones.
58#[derive(Clone, Copy)]
59struct ChartColors {
60    /// Primary axis / legend text (e.g. axis labels, table headers).
61    axis: PackedRgba,
62    /// Secondary / muted text (e.g. row labels, small metadata).
63    muted: PackedRgba,
64    /// Tertiary / very subtle text (e.g. grid lines, separators).
65    subtle: PackedRgba,
66    /// Bright emphasis text (e.g. highlighted values, headers).
67    emphasis: PackedRgba,
68    /// Tooltip background.
69    tooltip_bg: PackedRgba,
70    /// Tooltip foreground.
71    tooltip_fg: PackedRgba,
72    /// Highlight/selected marker (yellow tones).
73    highlight: PackedRgba,
74    /// Highlight dimmed variant.
75    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// ---------------------------------------------------------------------------
107// AnalyticsChartData — pre-computed chart data
108// ---------------------------------------------------------------------------
109
110/// Pre-computed chart data for the analytics views.
111///
112/// Loaded once when entering the analytics surface, refreshed on filter changes.
113#[derive(Clone, Debug, Default)]
114pub struct AnalyticsChartData {
115    /// Per-agent token totals: `(agent_slug, api_tokens_total)` sorted desc.
116    pub agent_tokens: Vec<(String, f64)>,
117    /// Per-agent message counts: `(agent_slug, message_count)` sorted desc.
118    pub agent_messages: Vec<(String, f64)>,
119    /// Per-agent tool call counts: `(agent_slug, tool_call_count)` sorted desc.
120    pub agent_tool_calls: Vec<(String, f64)>,
121    // ── Workspace breakdowns ─────────────────────────────────────
122    /// Per-workspace token totals: `(workspace_path, api_tokens_total)` sorted desc.
123    pub workspace_tokens: Vec<(String, f64)>,
124    /// Per-workspace message counts: `(workspace_path, message_count)` sorted desc.
125    pub workspace_messages: Vec<(String, f64)>,
126    // ── Source breakdowns ────────────────────────────────────────
127    /// Per-source token totals: `(source_id, api_tokens_total)` sorted desc.
128    pub source_tokens: Vec<(String, f64)>,
129    /// Per-source message counts: `(source_id, message_count)` sorted desc.
130    pub source_messages: Vec<(String, f64)>,
131    /// Daily timeseries: `(label, api_tokens_total)` ordered by date.
132    pub daily_tokens: Vec<(String, f64)>,
133    /// Daily timeseries: `(label, message_count)` ordered by date.
134    pub daily_messages: Vec<(String, f64)>,
135    /// Per-model token totals: `(model_family, grand_total_tokens)` sorted desc.
136    pub model_tokens: Vec<(String, f64)>,
137    /// Coverage percentage (0..100).
138    pub coverage_pct: f64,
139    /// Total messages across all data.
140    pub total_messages: i64,
141    /// Total API tokens across all data.
142    pub total_api_tokens: i64,
143    /// Total tool calls across all data.
144    pub total_tool_calls: i64,
145    /// Number of unique agents seen.
146    pub agent_count: usize,
147    /// Per-day heatmap values: `(day_label, normalized_value 0..1)`.
148    pub heatmap_days: Vec<(String, f64)>,
149
150    // ── Dashboard KPI extras ─────────────────────────────────────
151    /// Total content-estimated tokens across all data.
152    pub total_content_tokens: i64,
153    /// Daily content tokens: `(label, content_tokens_est_total)`.
154    pub daily_content_tokens: Vec<(String, f64)>,
155    /// Daily tool calls: `(label, tool_call_count)`.
156    pub daily_tool_calls: Vec<(String, f64)>,
157    /// Total plan messages.
158    pub total_plan_messages: i64,
159    /// Daily plan messages: `(label, plan_message_count)`.
160    pub daily_plan_messages: Vec<(String, f64)>,
161    /// Per-session points for Explorer scatter (x=messages, y=API tokens).
162    pub session_scatter: Vec<crate::analytics::SessionScatterPoint>,
163    // ── Tools view (enhanced) ─────────────────────────────────
164    /// Full tool report rows (agent → calls, msgs, tokens, derived metrics).
165    pub tool_rows: Vec<crate::analytics::ToolRow>,
166
167    // ── Plans view ───────────────────────────────────────────
168    /// Per-agent plan message counts: `(agent_slug, plan_message_count)` sorted desc.
169    pub agent_plan_messages: Vec<(String, f64)>,
170    /// Plan message share (% of total messages that are plan messages).
171    pub plan_message_pct: f64,
172    /// Plan API token share (% of API tokens attributed to plans).
173    pub plan_api_token_share: f64,
174    /// True when analytics rollups were auto-rebuilt during the latest load.
175    pub auto_rebuilt: bool,
176    /// Captures auto-rebuild errors; data may still be partially available.
177    pub auto_rebuild_error: Option<String>,
178}
179
180impl AnalyticsChartData {
181    /// Returns true when the dataset contains no meaningful analytics data.
182    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
190// ---------------------------------------------------------------------------
191// Data loading
192// ---------------------------------------------------------------------------
193
194/// Load analytics data from the database, returning an `AnalyticsChartData`.
195///
196/// Gracefully returns empty data if the database is unavailable or tables
197/// are missing.
198pub 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    // Build filter from analytics filter state.
208    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    // Agent breakdown (Track A — usage_daily).
225    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    // Helper to log analytics query errors.
248    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    // Agent message counts.
262    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    // Workspace breakdown (Track A — usage_daily).
282    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    // Source breakdown (Track A — usage_daily).
318    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    // Tool usage — load full rows for the enhanced tools table.
354    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    // Per-session scatter points (messages vs API tokens).
369    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    // Daily timeseries (for sparklines and line chart).
378    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        // Build heatmap data (normalize token values to 0..1).
412        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    // Model breakdown (Track B — token_daily_stats).
432    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    // Coverage percentage.
451    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    // Per-agent plan message breakdown.
460    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    // Log summary of load errors.
479    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    // Derive plan share percentages from totals.
488    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
534// ---------------------------------------------------------------------------
535// Chart rendering — per-view functions
536// ---------------------------------------------------------------------------
537
538/// Render the Dashboard view: KPI tile wall with sparklines + top agents.
539pub 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; // too small to render
547    }
548
549    // Show a helpful empty state when no analytics data has been loaded.
550    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    // Compute exact height needed for agent bar chart (1 row per agent).
649    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),            // KPI tile grid
670                Constraint::Fixed(max_bar_rows), // Top bar charts (exact fit)
671                Constraint::Fixed(2),            // Aggregate sparkline (label + bars)
672                Constraint::Min(0),              // Remaining space
673            ])
674            .split(area)
675    } else {
676        Flex::vertical()
677            .constraints([
678                Constraint::Fixed(6), // KPI tile grid
679                Constraint::Fixed(2), // Aggregate sparkline (label + bars)
680                Constraint::Min(0),   // Remaining space
681            ])
682            .split(area)
683    };
684
685    // ── KPI Tile Grid ──────────────────────────────────────────
686    render_kpi_tiles(data, chunks[0], frame, dark_mode);
687
688    // ── Top Bar Charts (manual rendering with full labels) ──
689    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        // Inner function to render a mini bar chart.
702        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                    // Correctly handle display width for truncation
758                    let truncated_name = shorten_label(name, label_w as usize);
759                    let val_str = format_compact(*val as i64);
760
761                    // To avoid padding issues with wide characters, we calculate exactly how many
762                    // spaces are needed instead of relying on the std::fmt width padder which uses chars
763                    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    // ── Aggregate Token Sparkline ────────────────────────────────
818    let sparkline_chunk = if has_bar { chunks[2] } else { chunks[1] };
819    if !data.daily_tokens.is_empty() && sparkline_chunk.height >= 2 {
820        // Render label on first row.
821        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        // Sparkline fills remaining rows.
834        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
852/// Render the KPI tile grid: 2 rows × 3 columns of metric tiles.
853fn 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    // 2 rows of tiles, 3 tiles per row
862    let rows = Flex::vertical()
863        .constraints([Constraint::Fixed(3), Constraint::Fixed(3)])
864        .split(area);
865
866    // Row 1: API Tokens | Messages | Tool Calls
867    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), // cyan
880        PackedRgba::rgb(0, 100, 200), // dark cyan
881        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), // green
890        PackedRgba::rgb(40, 150, 40),   // dark green
891        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), // orange
900        PackedRgba::rgb(200, 100, 0), // dark orange
901        cc.muted,
902        cols1[2],
903        frame,
904    );
905
906    // Row 2: Content Tokens | Plan Messages | Coverage
907    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), // lavender
920        PackedRgba::rgb(120, 60, 200),  // dark lavender
921        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), // gold
930        PackedRgba::rgb(180, 140, 0), // dark gold
931        cc.muted,
932        cols2[1],
933        frame,
934    );
935
936    render_kpi_tile(
937        "API Cvg",
938        &format!("{:.0}%", data.coverage_pct),
939        &[],                            // no sparkline for coverage
940        PackedRgba::rgb(150, 200, 255), // light blue
941        PackedRgba::rgb(80, 120, 180),  // muted blue
942        cc.muted,
943        cols2[2],
944        frame,
945    );
946}
947
948/// Render a single KPI tile: label (dim) + value (bright) + mini sparkline.
949#[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    // Row 1: label (dimmed)
965    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    // Row 2: big value + inline sparkline
976    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    // Mini sparkline in remaining space on row 2
991    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    // Optional Row 3: burn rate or delta (if height allows).
1009    // Require >= 14 data points so both 7-day windows are fully populated.
1010    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)) // ▲ red (up)
1034            } else if pct < -5.0 {
1035                ("\u{25bc}", PackedRgba::rgb(80, 200, 80)) // ▼ green (down)
1036            } else {
1037                ("\u{25c6}", label_muted) // ◆ muted (flat)
1038            };
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
1047/// Format a number compactly: 1.2B, 45.3M, 12.5K, or raw for small values.
1048fn 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
1061/// Render the Explorer view: interactive metric selector + line area/scatter charts.
1062pub 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    // Select the data series based on the active metric.
1074    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    // Layout: header (2 lines) + chart (flex)
1124    let chunks = Flex::vertical()
1125        .constraints([Constraint::Fixed(2), Constraint::Min(4)])
1126        .split(area);
1127
1128    // ── Header: metric selector + overlay + total + data range ──
1129    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    // ── Build primary + overlay series ──────────────────────────
1167    let primary_points: Vec<(f64, f64)> = metric_data
1168        .iter()
1169        .enumerate()
1170        .map(|(i, (_, v))| (i as f64, *v))
1171        .collect();
1172
1173    // Dimension overlay: add a series per top-N item (max 5 for readability).
1174    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    // X labels: first, mid, last date.
1207    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            // Keep labels legible on narrow charts by skipping overlapping slots.
1523            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    // Axes and average guides.
1625    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; // keeps call sites readable; metric-specific formatting removed
1666    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
1716/// Get the daily series data and color for a given explorer metric.
1717fn 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
1732/// Build per-agent overlay series. Each agent gets its own Vec<(f64, f64)>.
1733///
1734/// Simplified proportional overlay — distributes the daily totals by each
1735/// dimension item's share of the overall breakdown total. A full implementation
1736/// would query per-dimension timeseries, but this approximation works for v1.
1737fn 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
1760/// Select the heatmap timeseries and raw values for the given metric.
1761///
1762/// Returns `(series, min_raw, max_raw)` where series items are `(label, normalised 0..1)`.
1763fn 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
1810/// Format a raw heatmap value for tooltip display.
1811fn 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
1821/// Day-of-week labels for the left gutter (Mon, Wed, Fri shown; others blank).
1822const DOW_LABELS: [&str; 7] = ["Mon", "", "Wed", "", "Fri", "", ""];
1823
1824/// Parse a "YYYY-MM-DD" label into (year, month, day).
1825fn 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/// Compute ISO weekday for a date (Mon=0 .. Sun=6) using Tomohiko Sakamoto's method.
1837#[allow(dead_code)] // used in tests; reserved for future calendar-aligned layout
1838fn 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    // Sakamoto gives Sun=0, Mon=1 … Sat=6; convert to Mon=0 … Sun=6.
1844    ((dow + 6) % 7) as usize
1845}
1846
1847/// Render the Heatmap view: GitHub-contributions-style calendar with metric
1848/// selector, day-of-week labels, month headers, selection highlight, and legend.
1849pub 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    // ── Layout: metric tabs (1) + month labels (1) + grid body (min 5) + legend (1)
1924    let min_body = 5u16;
1925    if area.height < 4 {
1926        // Fallback: too small, just show a sparkline.
1927        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),        // metric tab bar
1939            Constraint::Fixed(1),        // month labels row
1940            Constraint::Min(min_body),   // grid body
1941            Constraint::Fixed(legend_h), // legend
1942        ])
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    // ── 1. Metric tab bar ───────────────────────────────────────────────
1950    render_heatmap_tabs(metric, tab_area, frame, cc);
1951
1952    // ── 2. Compute grid geometry ────────────────────────────────────────
1953    let left_gutter = 4u16; // "Mon " = 4 chars
1954    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; // days of week
1962    let day_count = (series.len().min(u16::MAX as usize)) as u16;
1963    let cols = day_count.div_ceil(rows);
1964
1965    // Determine how many weeks we can show given available width.
1966    // Each column needs at least 2 chars wide to be readable.
1967    let max_cols = grid_inner.width / 2;
1968    let visible_cols = cols.min(max_cols).max(1);
1969    // If we have more weeks than space, show the most recent N weeks.
1970    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    // ── 3. Day-of-week gutter labels ────────────────────────────────────
1979    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    // ── 4. Month header labels ──────────────────────────────────────────
1994    {
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; // only check first day of each column
2014            }
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    // ── 5. Heatmap grid (canvas) ────────────────────────────────────────
2037    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); // 1px column gap
2050        let fh = cell_h.max(1) as i32; // no row gap
2051        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    // ── 6. Selection highlight ──────────────────────────────────────────
2062    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                // Render a bright border/marker over the selected cell.
2079                let marker = if sw >= 2 {
2080                    "\u{25a0}".to_string() // filled square
2081                } else {
2082                    "\u{25b6}".to_string() // arrow
2083                };
2084                Paragraph::new(marker)
2085                    .style(ftui::Style::new().fg(cc.highlight).bold())
2086                    .render(sel_rect, frame);
2087            }
2088        }
2089    }
2090
2091    // ── 7. Tooltip: show selected day's date + value ────────────────────
2092    if selection < series.len() {
2093        let (label, norm) = &series[selection];
2094        // For Coverage the series values are raw fractions (0..1 = 0%..100%),
2095        // not values normalised against max_raw. Reconstruct accordingly.
2096        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        // Place tooltip at bottom-right of grid area.
2105        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    // ── 8. Legend: gradient ramp with min/max labels ─────────────────────
2119    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        // Left label
2128        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        // Gradient ramp in the middle
2139        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}") // full block
2153                    .style(ftui::Style::new().fg(color))
2154                    .render(cell_rect, frame);
2155            }
2156        }
2157
2158        // Right label
2159        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
2173/// Render the heatmap metric tab bar.
2174fn 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
2216/// Render the Breakdowns view: tabbed agent/workspace/source/model bar charts.
2217pub 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    // Select which data to display based on the active tab.
2231    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    // Layout: tab bar (1 row) | content (fill)
2297    let layout = Flex::vertical()
2298        .constraints([Constraint::Fixed(1), Constraint::Min(3)])
2299        .split(area);
2300
2301    // ── Tab bar ──────────────────────────────────────────
2302    render_breakdown_tabs(tab, layout[0], frame, cc);
2303
2304    // ── Content: side-by-side bar charts (tokens | messages) ─
2305    // Inset by 1 column to leave a gutter for the selection indicator (▶).
2306    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    // Determine how many rows we can fit (max 25 to avoid overwhelm).
2314    // BarChart uses 1 row per group + some overhead.
2315    let max_items = (content.height as usize).saturating_sub(2).clamp(8, 25);
2316
2317    // For Model tab, show a single tokens-only chart (no message counts).
2318    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    // Token breakdown.
2338    {
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    // Message breakdown.
2357    {
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
2376/// Color palette for non-agent breakdowns (workspaces, sources).
2377const 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
2444/// Render the tab selector bar for the Breakdowns view.
2445fn 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
2456/// Shorten a label (e.g., workspace path) to fit in `max_len` characters.
2457fn 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    // Take characters until we would exceed the column budget (minus 1 for ellipsis).
2474    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
2489/// Number of visible rows in the Tools view (for selection bounds).
2490pub fn tools_row_count(data: &AnalyticsChartData) -> usize {
2491    let max_visible = 20;
2492    data.tool_rows.len().min(max_visible)
2493}
2494
2495/// Number of visible rows in the Coverage view (for selection bounds).
2496pub fn coverage_row_count(data: &AnalyticsChartData) -> usize {
2497    data.agent_tokens.len().min(10)
2498}
2499
2500/// Render the Tools view: per-agent table with calls, messages, tokens, calls/1K, and trend.
2501pub 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    // Layout: header (1) | table rows (fill) | sparkline (3) | summary (1)
2561    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    // ── Header ──
2579    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    // ── Table rows ──
2586    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    // ── Daily tool calls sparkline ──
2609    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    // ── Summary ──
2618    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
2638/// Build the header line for the tools table.
2639fn 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
2673/// Format a single tool-report row into a table line.
2674fn 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
2722// Cost (USD) UI removed: pricing-derived token costs were misleading and not
2723// useful for cass UX. We keep model usage breakdowns via `model_tokens`.
2724
2725/// Number of selectable rows in the Plans view (per-agent plan breakdown).
2726pub fn plans_rows(data: &AnalyticsChartData) -> usize {
2727    data.agent_plan_messages.len().min(15)
2728}
2729
2730/// Render the Plans view: plan message breakdown by agent + plan token share.
2731fn 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    // Header
2752    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    // Per-agent plan message rows.
2774    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        // Bar
2812        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        // Label on top
2827        Paragraph::new(label)
2828            .style(ftui::Style::new().fg(fg))
2829            .render(row_area, frame);
2830    }
2831}
2832
2833/// Render the Coverage view: overall bar + per-agent breakdown + daily sparkline.
2834pub 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    // Agent rows to show (up to 10).
2843    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 // +1 header
2846    } else {
2847        0
2848    };
2849
2850    let chunks = Flex::vertical()
2851        .constraints([
2852            Constraint::Fixed(2),            // overall coverage bar
2853            Constraint::Fixed(table_height), // per-agent breakdown
2854            Constraint::Min(3),              // daily sparkline
2855        ])
2856        .split(area);
2857
2858    // ── Overall coverage bar ─────────────────────────────────
2859    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    // ── Per-agent coverage breakdown ─────────────────────────
2896    if agent_row_count > 0 && chunks[1].height > 0 {
2897        let w = chunks[1].width as usize;
2898        // Header.
2899        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        // Agent rows.
2923        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            // Agents with >0 API tokens have real API data.
2935            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            // Overlay data indicator in its own color at the right edge.
2980            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    // ── Daily token sparkline ────────────────────────────────
3001    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
3087/// Explorer view state passed to the render function.
3088pub 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/// Dispatch rendering to the appropriate view function.
3096///
3097/// `selection` is the currently highlighted item index (for drilldown).
3098#[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            // Offset by 1 for the tab bar row.
3120            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            // Selection indicator offset by 1 for the header row.
3142            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            // Selection indicator offset by 2 for the coverage bar + 1 for table header.
3167            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, // 2-row coverage bar + 1-row table header
3172                    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
3188/// Number of selectable rows in the Breakdowns view for the given tab.
3189pub 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
3198/// Overlay a `▶` selection indicator at the given row index within `area`.
3199///
3200/// If `half_width` is true, the indicator is placed in the left half of the area
3201/// (for split-pane views like Breakdowns).
3202fn 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
3237// ---------------------------------------------------------------------------
3238// Helpers
3239// ---------------------------------------------------------------------------
3240
3241/// Format a large number with comma separators (e.g. 1234567 → "1,234,567").
3242fn 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// ---------------------------------------------------------------------------
3260// Tests
3261// ---------------------------------------------------------------------------
3262
3263#[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); // cycles at 14
3409    }
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        // Verify that rendering with empty data doesn't panic for any view.
3423        let data = AnalyticsChartData::default();
3424        // We can't easily create a frame in tests, but we can verify the
3425        // dispatch function compiles and the data structures are correct.
3426        let _ = &data;
3427        for view in AnalyticsView::all() {
3428            // Just verify the match arm exists for each view.
3429            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        // 2026-02-07 is a Saturday → index 5 (Mon=0..Sun=6)
3444        assert_eq!(weekday_index(2026, 2, 7), 5);
3445        // 2026-02-02 is a Monday → index 0
3446        assert_eq!(weekday_index(2026, 2, 2), 0);
3447        // 2026-01-01 is a Thursday → index 3
3448        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))); // parser doesn't validate ranges
3457    }
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        // Max value normalizes to 1.0
3483        assert!((series[1].1 - 1.0).abs() < 0.001);
3484        // Min value normalizes to 0.25
3485        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    // ── Tools view tests ──────────────────────────────────────────────
3527
3528    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}")); // em-dash for missing data
3635    }
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    // ── Coverage view tests ──────────────────────────────────────────
3667
3668    #[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        // Green for high coverage
3704        assert_eq!(green, PackedRgba::rgb(80, 200, 80));
3705        // Yellow for moderate
3706        assert_eq!(yellow, PackedRgba::rgb(255, 200, 0));
3707        // Red for low
3708        assert_eq!(red, PackedRgba::rgb(255, 80, 80));
3709    }
3710}