//! Analytics dashboard — revenue chart, stats, top projects, transactions, export. use makeover_tui::makeover_layout::{Column, Priority, Width}; use makeover_tui::table::{self, Cell, Sizing, TableStyle}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, TableState}; use crate::format; use super::App; /// The top-projects columns. Both are essential: a revenue ranking missing /// either the name or the amount is not a narrower table, it is a different /// one. const TOP_PROJECT_COLUMNS: [Column<'static>; 2] = [ Column { name: "Project", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Revenue", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }, ]; /// See the home-screen revenue column on why 15 rather than 12. const TOP_PROJECT_SIZING: Sizing<'static> = Sizing { lengths: &[("Project", 20), ("Revenue", 15)], fallback: 8, }; /// The transaction columns. What was bought and for how much is the row; the /// status and the date qualify it. const TRANSACTION_COLUMNS: [Column<'static>; 4] = [ Column { name: "Item", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Amount", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Status", width: Width::Fixed, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "Date", width: Width::Fixed, priority: Priority::Optional, sortable: false, sorted: None, }, ]; /// The tracks the hand-written `Constraint`s carried. const TRANSACTION_SIZING: Sizing<'static> = Sizing { lengths: &[("Item", 20), ("Amount", 12), ("Status", 10), ("Date", 12)], fallback: 8, }; pub(crate) fn render(frame: &mut Frame, app: &App) { let area = frame.area(); let range_label = match app.analytics_range.as_str() { "7d" => "7 days", "30d" => "30 days", "90d" => "90 days", "all" => "All time", _ => &app.analytics_range, }; let title = Line::from(vec![ Span::styled( " Makenot.work ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" -- "), Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" ({range_label}) ")), ]); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.line_border)); let inner = block.inner(area); frame.render_widget(block, area); let chunks = Layout::vertical([ Constraint::Length(1), // spacer Constraint::Length(3), // stat cards Constraint::Length(1), // spacer Constraint::Min(6), // chart or transactions Constraint::Length(1), // status Constraint::Length(1), // keybindings ]) .split(inner); // Stat cards render_stat_cards(frame, app, chunks[1]); // Main content area if app.loading { let loading = Paragraph::new(" Loading..."); frame.render_widget(loading, chunks[3]); } else if app.analytics_show_transactions { render_transactions(frame, app, chunks[3]); } else { render_chart_and_projects(frame, app, chunks[3]); } // Status line if let Some(ref status) = app.analytics_status { let style = if status.starts_with("Error") { Style::default().fg(app.theme.status_danger) } else { Style::default().fg(app.theme.status_success) }; let status_line = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled(status.as_str(), style), ])); frame.render_widget(status_line, chunks[4]); } // Keybindings let key_spans = vec![ Span::raw(" "), Span::styled("[1-4]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Range "), Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Transactions "), Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Export CSV "), Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Refresh "), Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Back"), ]; let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted)); frame.render_widget(keys, chunks[5]); } fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let stat_chunks = Layout::horizontal([ Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), ]) .split(area); let data = &app.analytics_data; let stats = [ ( "Revenue", format::format_cents( data.as_ref().map_or(0, |d| d.current_revenue_cents), app.currency(), ), data.as_ref() .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), ), ( "Sales", data.as_ref() .map_or_else(|| "--".into(), |d| d.current_sales.to_string()), data.as_ref() .map(|d| pct_change(d.current_sales, d.previous_sales)), ), ( "Followers", data.as_ref() .map_or_else(|| "--".into(), |d| d.current_followers.to_string()), data.as_ref() .map(|d| pct_change(d.current_followers, d.previous_followers)), ), ]; for (i, (label, value, change)) in stats.iter().enumerate() { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.content_muted)); let inner = block.inner(stat_chunks[i]); frame.render_widget(block, stat_chunks[i]); let mut spans = vec![ Span::styled( format!(" {value}"), Style::default().add_modifier(Modifier::BOLD), ), Span::styled( format!(" {label}"), Style::default().fg(app.theme.content_muted), ), ]; if let Some(Some((text, positive))) = change { let color = if *positive { app.theme.status_success } else { app.theme.status_danger }; spans.push(Span::styled(format!(" {text}"), Style::default().fg(color))); } let text = Paragraph::new(Line::from(spans)); frame.render_widget(text, inner); } } fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let chunks = Layout::vertical([ Constraint::Length(1), // chart header Constraint::Min(4), // chart Constraint::Length(1), // spacer Constraint::Length(1), // projects header Constraint::Min(2), // projects table ]) .split(area); // Chart header let header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)), ])); frame.render_widget(header, chunks[0]); // Revenue bar chart if let Some(ref data) = app.analytics_data { if data.buckets.is_empty() { let empty = Paragraph::new(" No revenue data for this period."); frame.render_widget(empty, chunks[1]); } else { let max_val = data .buckets .iter() .map(|b| b.revenue_cents) .max() .unwrap_or(1) .max(1); let bars: Vec = data .buckets .iter() .map(|b| { Bar::default() .value(b.revenue_cents as u64) .label(Line::from(b.label.clone())) .text_value(if b.revenue_cents > 0 { format::format_cents(b.revenue_cents, app.currency()) } else { String::new() }) .style(Style::default().fg(app.theme.action_primary)) }) .collect(); let chart = BarChart::default() .data(BarGroup::default().bars(&bars)) .bar_width( (chunks[1].width as usize) .checked_div(bars.len().max(1)) .unwrap_or(3) .clamp(1, 8) as u16, ) .bar_gap(1) .max(max_val as u64); frame.render_widget(chart, chunks[1]); } } else { let empty = Paragraph::new(" No data."); frame.render_widget(empty, chunks[1]); } // Projects header let proj_header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled( "Top Projects", Style::default().add_modifier(Modifier::BOLD), ), ])); frame.render_widget(proj_header, chunks[3]); // Top projects if let Some(ref data) = app.analytics_data { if data.top_projects.is_empty() { let empty = Paragraph::new(" No project revenue yet."); frame.render_widget(empty, chunks[4]); } else { let area = super::indent(chunks[4], 2); let rows: Vec> = data .top_projects .iter() .map(|p| { vec![ Cell::new("Project", p.title.as_str()), Cell::new("Revenue", p.revenue().display_compact(app.currency())), ] }) .collect(); // Nothing selects a row here, so this one draws without a state. let style = TableStyle::from_theme(&app.theme); let table = table::table( &TOP_PROJECT_COLUMNS, &rows, &TOP_PROJECT_SIZING, &style, area.width, ); frame.render_widget(table, area); } } } fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let chunks = Layout::vertical([ Constraint::Length(1), // header Constraint::Min(3), // table ]) .split(area); let count = app.transactions.len(); let header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled( "Transactions", Style::default().add_modifier(Modifier::BOLD), ), if count == 0 { Span::raw("") } else { Span::raw(format!(" ({count})")) }, ])); frame.render_widget(header, chunks[0]); if app.transactions.is_empty() { let empty = Paragraph::new(" No transactions."); frame.render_widget(empty, chunks[1]); } else { let area = super::indent(chunks[1], 2); let rows: Vec> = app .transactions .iter() .map(|tx| { vec![ Cell::new("Item", tx.item_title.as_deref().unwrap_or("--")), Cell::new( "Amount", format::format_cents(i64::from(tx.amount_cents), app.currency()), ), Cell::new("Status", tx.status.as_str()), Cell::new("Date", tx.created_at.get(..10).unwrap_or(&tx.created_at)), ] }) .collect(); let style = TableStyle::from_theme(&app.theme); let table = table::table( &TRANSACTION_COLUMNS, &rows, &TRANSACTION_SIZING, &style, area.width, ); let mut state = TableState::default().with_selected(Some(app.selected_index)); frame.render_stateful_widget(table, area, &mut state); } } fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> { if previous == 0 { if current > 0 { return Some(("+inf%".to_string(), true)); } return None; } let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64; if change == 0 { None } else { let text = if change > 0 { format!("+{change}%") } else { format!("{change}%") }; Some((text, change > 0)) } }