claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
pub mod widgets;
pub mod views;

use ratatui::{
    layout::{Constraint, Layout, Rect},
    style::{Color, Style, Stylize},
    text::{Line, Span},
    Frame,
};
use chrono::Datelike;
use crate::app::{App, Tab};
use crate::i18n;
use crate::llm::LlmReportType;

/// Render the full TUI frame.
pub fn render(frame: &mut Frame, app: &App) {
    let area = frame.area();

    // Main vertical layout
    let main_chunks = Layout::vertical([
        Constraint::Length(1),  // Title bar
        Constraint::Length(1),  // Tab bar
        Constraint::Min(3),     // Content area
        Constraint::Length(1),  // Status bar
    ])
    .split(area);

    render_title_bar(frame, main_chunks[0], app);
    render_tab_bar(frame, main_chunks[1], app);
    render_content(frame, main_chunks[2], app);
    render_status_bar(frame, main_chunks[3], app);
}

fn render_title_bar(frame: &mut Frame, area: Rect, app: &App) {
    let t = i18n::t();
    let title = match app.active_tab {
        Tab::Summary => format!(
            "{} | {} | First session: {}",
            t.title_dashboard,
            t.title_all_time,
            app.stats
                .first_session_date
                .as_deref()
                .unwrap_or("N/A")
        ),
        Tab::Monthly => {
            let y = app.selected_month.year();
            let m = app.selected_month.month();
            let month_str = chrono::Month::try_from(m as u8)
                .map(|mo| format!("{:?}", mo))
                .unwrap_or_else(|_| m.to_string());
            format!("{} | {} {}", t.title_dashboard, month_str, y)
        }
        Tab::Yearly => format!("{} | {}", t.title_dashboard, app.selected_year),
        Tab::LlmSummary => {
            let label = match app.llm.report_type {
                LlmReportType::All => t.title_all_time.to_string(),
                LlmReportType::Monthly => {
                    let m = chrono::Month::try_from(app.llm.selected_month.month() as u8)
                        .map(|mo| format!("{:?}", mo))
                        .unwrap_or_else(|_| app.llm.selected_month.month().to_string());
                    format!("{} {}", m, app.llm.selected_month.year())
                }
                LlmReportType::Yearly => app.llm.selected_year.to_string(),
            };
            format!("{} | {} ({})", t.title_dashboard, t.title_llm_summary, label)
        }
    };

    let line = Line::from(vec![Span::styled(
        title,
        Style::default().fg(Color::Reset),
    )]);

    let p = ratatui::widgets::Paragraph::new(line)
        .style(Style::default().bg(Color::Reset));
    frame.render_widget(p, area);
}

fn render_tab_bar(frame: &mut Frame, area: Rect, app: &App) {
    let t = i18n::t();
    let tabs = vec![
        (t.tab_overview, Tab::Summary),
        (t.tab_monthly, Tab::Monthly),
        (t.tab_yearly, Tab::Yearly),
        (t.tab_llm, Tab::LlmSummary),
    ];

    let spans: Vec<Span> = tabs
        .iter()
        .map(|(label, tab)| {
            if *tab == app.active_tab {
                Span::styled(
                    format!(" {} ", label),
                    Style::default().fg(Color::Black).bg(Color::Cyan).bold(),
                )
            } else {
                Span::styled(
                    format!(" {} ", label),
                    Style::default().fg(Color::Gray).bg(Color::Reset),
                )
            }
        })
        .collect();

    let line = Line::from(spans);
    let p = ratatui::widgets::Paragraph::new(line)
        .style(Style::default().bg(Color::Reset));
    frame.render_widget(p, area);
}

fn render_content(frame: &mut Frame, area: Rect, app: &App) {
    match app.active_tab {
        Tab::Summary => views::render_summary_view(frame, area, app),
        Tab::Monthly => views::render_monthly_view(frame, area, app),
        Tab::Yearly => views::render_yearly_view(frame, area, app),
        Tab::LlmSummary => views::render_llm_summary_view(frame, area, app),
    }
}

fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) {
    let t = i18n::t();
    let parse_errors = app.parse_error_count;
    let errors_str = if parse_errors > 0 {
        format!(" | {}: {}", t.status_parse_errors, parse_errors)
    } else {
        String::new()
    };

    let loading_str = if app.load_state == crate::app::LoadState::Loading {
        format!(" | {}", t.status_loading)
    } else {
        String::new()
    };

    let hint = if app.active_tab == Tab::LlmSummary {
        format!(" | {}", t.status_llm_hint)
    } else {
        String::new()
    };

    let status = format!(
        "{} | {} ({}) | {} | {} | {} | {}{}{}{}",
        t.status_scroll,
        t.status_tab_focus,
        app.ui.focus_section.name(),
        t.status_navigate,
        t.status_copy,
        t.status_refresh,
        t.status_quit,
        errors_str, loading_str, hint
    );

    let line = Line::from(vec![Span::styled(
        status,
        Style::default().fg(Color::DarkGray).bg(Color::Reset),
    )]);

    let p = ratatui::widgets::Paragraph::new(line)
        .style(Style::default().bg(Color::Reset));
    frame.render_widget(p, area);
}