//! Analytics dashboard — revenue chart, stats, top projects, transactions, export. use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row}; use crate::format; use super::App; use super::widgets; pub 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(Color::Gray)); 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(Color::Red) } else { Style::default().fg(Color::Green) }; 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(Color::DarkGray)); 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(|d| d.current_revenue_cents).unwrap_or(0)), data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), ), ( "Sales", data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()), data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)), ), ( "Followers", data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()), 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(Color::DarkGray)); 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(Color::DarkGray)), ]; if let Some(Some((text, positive))) = change { let color = if *positive { Color::Green } else { Color::Red }; 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) } else { String::new() }) .style(Style::default().fg(Color::Cyan)) }) .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 rows: Vec = data .top_projects .iter() .map(|p| { Row::new(vec![ format!(" {}", p.title), format::format_cents(p.revenue_cents), ]) }) .collect(); let widths = [Constraint::Min(20), Constraint::Length(12)]; widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows); } } } 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 rows: Vec = app .transactions .iter() .enumerate() .map(|(i, tx)| { let title = tx.item_title.as_deref().unwrap_or("--"); let amount = format::format_cents(tx.amount_cents as i64); let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); Row::new(vec![ format!(" {}", title), amount, tx.status.clone(), date.to_string(), ]) .style(widgets::selected_style(i, Some(app.selected_index))) }) .collect(); let widths = [ Constraint::Min(20), Constraint::Length(12), Constraint::Length(10), Constraint::Length(12), ]; widgets::render_table(frame, chunks[1], &[" Item", "Amount", "Status", "Date"], &widths, rows); } } 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)) } }