Skip to main content

max / mnw-cli

10.2 KB · 310 lines History Blame Raw
1 //! Analytics dashboard — revenue chart, stats, top projects, transactions, export.
2
3 use ratatui::Frame;
4 use ratatui::layout::{Constraint, Layout};
5 use ratatui::style::{Color, Modifier, Style};
6 use ratatui::text::{Line, Span};
7 use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row};
8
9 use crate::format;
10
11 use super::App;
12 use super::widgets;
13
14 pub fn render(frame: &mut Frame, app: &App) {
15 let area = frame.area();
16
17 let range_label = match app.analytics_range.as_str() {
18 "7d" => "7 days",
19 "30d" => "30 days",
20 "90d" => "90 days",
21 "all" => "All time",
22 _ => &app.analytics_range,
23 };
24
25 let title = Line::from(vec![
26 Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
27 Span::raw(" -- "),
28 Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)),
29 Span::raw(format!(" ({}) ", range_label)),
30 ]);
31
32 let block = Block::default()
33 .title(title)
34 .borders(Borders::ALL)
35 .border_style(Style::default().fg(Color::Gray));
36
37 let inner = block.inner(area);
38 frame.render_widget(block, area);
39
40 let chunks = Layout::vertical([
41 Constraint::Length(1), // spacer
42 Constraint::Length(3), // stat cards
43 Constraint::Length(1), // spacer
44 Constraint::Min(6), // chart or transactions
45 Constraint::Length(1), // status
46 Constraint::Length(1), // keybindings
47 ])
48 .split(inner);
49
50 // Stat cards
51 render_stat_cards(frame, app, chunks[1]);
52
53 // Main content area
54 if app.loading {
55 let loading = Paragraph::new(" Loading...");
56 frame.render_widget(loading, chunks[3]);
57 } else if app.analytics_show_transactions {
58 render_transactions(frame, app, chunks[3]);
59 } else {
60 render_chart_and_projects(frame, app, chunks[3]);
61 }
62
63 // Status line
64 if let Some(ref status) = app.analytics_status {
65 let style = if status.starts_with("Error") {
66 Style::default().fg(Color::Red)
67 } else {
68 Style::default().fg(Color::Green)
69 };
70 let status_line = Paragraph::new(Line::from(vec![
71 Span::raw(" "),
72 Span::styled(status.as_str(), style),
73 ]));
74 frame.render_widget(status_line, chunks[4]);
75 }
76
77 // Keybindings
78 let key_spans = vec![
79 Span::raw(" "),
80 Span::styled("[1-4]", Style::default().add_modifier(Modifier::BOLD)),
81 Span::raw(" Range "),
82 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
83 Span::raw(" Transactions "),
84 Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
85 Span::raw(" Export CSV "),
86 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
87 Span::raw(" Refresh "),
88 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
89 Span::raw(" Back"),
90 ];
91
92 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
93 frame.render_widget(keys, chunks[5]);
94 }
95
96 fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
97 let stat_chunks = Layout::horizontal([
98 Constraint::Ratio(1, 3),
99 Constraint::Ratio(1, 3),
100 Constraint::Ratio(1, 3),
101 ])
102 .split(area);
103
104 let data = &app.analytics_data;
105
106 let stats = [
107 (
108 "Revenue",
109 format::format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)),
110 data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
111 ),
112 (
113 "Sales",
114 data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()),
115 data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)),
116 ),
117 (
118 "Followers",
119 data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()),
120 data.as_ref().map(|d| pct_change(d.current_followers, d.previous_followers)),
121 ),
122 ];
123
124 for (i, (label, value, change)) in stats.iter().enumerate() {
125 let block = Block::default()
126 .borders(Borders::ALL)
127 .border_style(Style::default().fg(Color::DarkGray));
128 let inner = block.inner(stat_chunks[i]);
129 frame.render_widget(block, stat_chunks[i]);
130
131 let mut spans = vec![
132 Span::styled(
133 format!(" {value}"),
134 Style::default().add_modifier(Modifier::BOLD),
135 ),
136 Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
137 ];
138
139 if let Some(Some((text, positive))) = change {
140 let color = if *positive { Color::Green } else { Color::Red };
141 spans.push(Span::styled(format!(" {text}"), Style::default().fg(color)));
142 }
143
144 let text = Paragraph::new(Line::from(spans));
145 frame.render_widget(text, inner);
146 }
147 }
148
149 fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
150 let chunks = Layout::vertical([
151 Constraint::Length(1), // chart header
152 Constraint::Min(4), // chart
153 Constraint::Length(1), // spacer
154 Constraint::Length(1), // projects header
155 Constraint::Min(2), // projects table
156 ])
157 .split(area);
158
159 // Chart header
160 let header = Paragraph::new(Line::from(vec![
161 Span::raw(" "),
162 Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)),
163 ]));
164 frame.render_widget(header, chunks[0]);
165
166 // Revenue bar chart
167 if let Some(ref data) = app.analytics_data {
168 if data.buckets.is_empty() {
169 let empty = Paragraph::new(" No revenue data for this period.");
170 frame.render_widget(empty, chunks[1]);
171 } else {
172 let max_val = data.buckets.iter().map(|b| b.revenue_cents).max().unwrap_or(1).max(1);
173
174 let bars: Vec<Bar> = data
175 .buckets
176 .iter()
177 .map(|b| {
178 Bar::default()
179 .value(b.revenue_cents as u64)
180 .label(Line::from(b.label.clone()))
181 .text_value(if b.revenue_cents > 0 {
182 format::format_cents(b.revenue_cents)
183 } else {
184 String::new()
185 })
186 .style(Style::default().fg(Color::Cyan))
187 })
188 .collect();
189
190 let chart = BarChart::default()
191 .data(BarGroup::default().bars(&bars))
192 .bar_width(
193 (chunks[1].width as usize)
194 .checked_div(bars.len().max(1))
195 .unwrap_or(3)
196 .clamp(1, 8) as u16,
197 )
198 .bar_gap(1)
199 .max(max_val as u64);
200
201 frame.render_widget(chart, chunks[1]);
202 }
203 } else {
204 let empty = Paragraph::new(" No data.");
205 frame.render_widget(empty, chunks[1]);
206 }
207
208 // Projects header
209 let proj_header = Paragraph::new(Line::from(vec![
210 Span::raw(" "),
211 Span::styled("Top Projects", Style::default().add_modifier(Modifier::BOLD)),
212 ]));
213 frame.render_widget(proj_header, chunks[3]);
214
215 // Top projects
216 if let Some(ref data) = app.analytics_data {
217 if data.top_projects.is_empty() {
218 let empty = Paragraph::new(" No project revenue yet.");
219 frame.render_widget(empty, chunks[4]);
220 } else {
221 let rows: Vec<Row> = data
222 .top_projects
223 .iter()
224 .map(|p| {
225 Row::new(vec![
226 format!(" {}", p.title),
227 format::format_cents(p.revenue_cents),
228 ])
229 })
230 .collect();
231
232 let widths = [Constraint::Min(20), Constraint::Length(12)];
233 widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows);
234 }
235 }
236 }
237
238 fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
239 let chunks = Layout::vertical([
240 Constraint::Length(1), // header
241 Constraint::Min(3), // table
242 ])
243 .split(area);
244
245 let count = app.transactions.len();
246 let header = Paragraph::new(Line::from(vec![
247 Span::raw(" "),
248 Span::styled("Transactions", Style::default().add_modifier(Modifier::BOLD)),
249 if count == 0 {
250 Span::raw("")
251 } else {
252 Span::raw(format!(" ({})", count))
253 },
254 ]));
255 frame.render_widget(header, chunks[0]);
256
257 if app.transactions.is_empty() {
258 let empty = Paragraph::new(" No transactions.");
259 frame.render_widget(empty, chunks[1]);
260 } else {
261 let rows: Vec<Row> = app
262 .transactions
263 .iter()
264 .enumerate()
265 .map(|(i, tx)| {
266 let title = tx.item_title.as_deref().unwrap_or("--");
267 let amount = format::format_cents(tx.amount_cents as i64);
268 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
269
270 Row::new(vec![
271 format!(" {}", title),
272 amount,
273 tx.status.clone(),
274 date.to_string(),
275 ])
276 .style(widgets::selected_style(i, Some(app.selected_index)))
277 })
278 .collect();
279
280 let widths = [
281 Constraint::Min(20),
282 Constraint::Length(12),
283 Constraint::Length(10),
284 Constraint::Length(12),
285 ];
286
287 widgets::render_table(frame, chunks[1], &[" Item", "Amount", "Status", "Date"], &widths, rows);
288 }
289 }
290
291 fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
292 if previous == 0 {
293 if current > 0 {
294 return Some(("+inf%".to_string(), true));
295 }
296 return None;
297 }
298 let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64;
299 if change == 0 {
300 None
301 } else {
302 let text = if change > 0 {
303 format!("+{}%", change)
304 } else {
305 format!("{}%", change)
306 };
307 Some((text, change > 0))
308 }
309 }
310