Skip to main content

max / makenotwork

12.9 KB · 416 lines History Blame Raw
1 //! Analytics dashboard — revenue chart, stats, top projects, transactions, export.
2
3 use makeover_tui::makeover_layout::{Column, Priority, Width};
4 use makeover_tui::table::{self, Cell, Sizing, TableStyle};
5 use ratatui::Frame;
6 use ratatui::layout::{Constraint, Layout};
7 use ratatui::style::{Modifier, Style};
8 use ratatui::text::{Line, Span};
9 use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, TableState};
10
11 use crate::format;
12
13 use super::App;
14
15 /// The top-projects columns. Both are essential: a revenue ranking missing
16 /// either the name or the amount is not a narrower table, it is a different
17 /// one.
18 const TOP_PROJECT_COLUMNS: [Column<'static>; 2] = [
19 Column {
20 name: "Project",
21 width: Width::Fill,
22 priority: Priority::Essential,
23 sortable: false,
24 sorted: None,
25 },
26 Column {
27 name: "Revenue",
28 width: Width::Fixed,
29 priority: Priority::Essential,
30 sortable: false,
31 sorted: None,
32 },
33 ];
34
35 /// See the home-screen revenue column on why 15 rather than 12.
36 const TOP_PROJECT_SIZING: Sizing<'static> = Sizing {
37 lengths: &[("Project", 20), ("Revenue", 15)],
38 fallback: 8,
39 };
40
41 /// The transaction columns. What was bought and for how much is the row; the
42 /// status and the date qualify it.
43 const TRANSACTION_COLUMNS: [Column<'static>; 4] = [
44 Column {
45 name: "Item",
46 width: Width::Fill,
47 priority: Priority::Essential,
48 sortable: false,
49 sorted: None,
50 },
51 Column {
52 name: "Amount",
53 width: Width::Fixed,
54 priority: Priority::Essential,
55 sortable: false,
56 sorted: None,
57 },
58 Column {
59 name: "Status",
60 width: Width::Fixed,
61 priority: Priority::Secondary,
62 sortable: false,
63 sorted: None,
64 },
65 Column {
66 name: "Date",
67 width: Width::Fixed,
68 priority: Priority::Optional,
69 sortable: false,
70 sorted: None,
71 },
72 ];
73
74 /// The tracks the hand-written `Constraint`s carried.
75 const TRANSACTION_SIZING: Sizing<'static> = Sizing {
76 lengths: &[("Item", 20), ("Amount", 12), ("Status", 10), ("Date", 12)],
77 fallback: 8,
78 };
79
80 pub(crate) fn render(frame: &mut Frame, app: &App) {
81 let area = frame.area();
82
83 let range_label = match app.analytics_range.as_str() {
84 "7d" => "7 days",
85 "30d" => "30 days",
86 "90d" => "90 days",
87 "all" => "All time",
88 _ => &app.analytics_range,
89 };
90
91 let title = Line::from(vec![
92 Span::styled(
93 " Makenot.work ",
94 Style::default().add_modifier(Modifier::BOLD),
95 ),
96 Span::raw(" -- "),
97 Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)),
98 Span::raw(format!(" ({range_label}) ")),
99 ]);
100
101 let block = Block::default()
102 .title(title)
103 .borders(Borders::ALL)
104 .border_style(Style::default().fg(app.theme.line_border));
105
106 let inner = block.inner(area);
107 frame.render_widget(block, area);
108
109 let chunks = Layout::vertical([
110 Constraint::Length(1), // spacer
111 Constraint::Length(3), // stat cards
112 Constraint::Length(1), // spacer
113 Constraint::Min(6), // chart or transactions
114 Constraint::Length(1), // status
115 Constraint::Length(1), // keybindings
116 ])
117 .split(inner);
118
119 // Stat cards
120 render_stat_cards(frame, app, chunks[1]);
121
122 // Main content area
123 if app.loading {
124 let loading = Paragraph::new(" Loading...");
125 frame.render_widget(loading, chunks[3]);
126 } else if app.analytics_show_transactions {
127 render_transactions(frame, app, chunks[3]);
128 } else {
129 render_chart_and_projects(frame, app, chunks[3]);
130 }
131
132 // Status line
133 if let Some(ref status) = app.analytics_status {
134 let style = if status.starts_with("Error") {
135 Style::default().fg(app.theme.status_danger)
136 } else {
137 Style::default().fg(app.theme.status_success)
138 };
139 let status_line = Paragraph::new(Line::from(vec![
140 Span::raw(" "),
141 Span::styled(status.as_str(), style),
142 ]));
143 frame.render_widget(status_line, chunks[4]);
144 }
145
146 // Keybindings
147 let key_spans = vec![
148 Span::raw(" "),
149 Span::styled("[1-4]", Style::default().add_modifier(Modifier::BOLD)),
150 Span::raw(" Range "),
151 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
152 Span::raw(" Transactions "),
153 Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
154 Span::raw(" Export CSV "),
155 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
156 Span::raw(" Refresh "),
157 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
158 Span::raw(" Back"),
159 ];
160
161 let keys =
162 Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
163 frame.render_widget(keys, chunks[5]);
164 }
165
166 fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
167 let stat_chunks = Layout::horizontal([
168 Constraint::Ratio(1, 3),
169 Constraint::Ratio(1, 3),
170 Constraint::Ratio(1, 3),
171 ])
172 .split(area);
173
174 let data = &app.analytics_data;
175
176 let stats = [
177 (
178 "Revenue",
179 format::format_cents(
180 data.as_ref().map_or(0, |d| d.current_revenue_cents),
181 app.currency(),
182 ),
183 data.as_ref()
184 .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
185 ),
186 (
187 "Sales",
188 data.as_ref()
189 .map_or_else(|| "--".into(), |d| d.current_sales.to_string()),
190 data.as_ref()
191 .map(|d| pct_change(d.current_sales, d.previous_sales)),
192 ),
193 (
194 "Followers",
195 data.as_ref()
196 .map_or_else(|| "--".into(), |d| d.current_followers.to_string()),
197 data.as_ref()
198 .map(|d| pct_change(d.current_followers, d.previous_followers)),
199 ),
200 ];
201
202 for (i, (label, value, change)) in stats.iter().enumerate() {
203 let block = Block::default()
204 .borders(Borders::ALL)
205 .border_style(Style::default().fg(app.theme.content_muted));
206 let inner = block.inner(stat_chunks[i]);
207 frame.render_widget(block, stat_chunks[i]);
208
209 let mut spans = vec![
210 Span::styled(
211 format!(" {value}"),
212 Style::default().add_modifier(Modifier::BOLD),
213 ),
214 Span::styled(
215 format!(" {label}"),
216 Style::default().fg(app.theme.content_muted),
217 ),
218 ];
219
220 if let Some(Some((text, positive))) = change {
221 let color = if *positive {
222 app.theme.status_success
223 } else {
224 app.theme.status_danger
225 };
226 spans.push(Span::styled(format!(" {text}"), Style::default().fg(color)));
227 }
228
229 let text = Paragraph::new(Line::from(spans));
230 frame.render_widget(text, inner);
231 }
232 }
233
234 fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
235 let chunks = Layout::vertical([
236 Constraint::Length(1), // chart header
237 Constraint::Min(4), // chart
238 Constraint::Length(1), // spacer
239 Constraint::Length(1), // projects header
240 Constraint::Min(2), // projects table
241 ])
242 .split(area);
243
244 // Chart header
245 let header = Paragraph::new(Line::from(vec![
246 Span::raw(" "),
247 Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)),
248 ]));
249 frame.render_widget(header, chunks[0]);
250
251 // Revenue bar chart
252 if let Some(ref data) = app.analytics_data {
253 if data.buckets.is_empty() {
254 let empty = Paragraph::new(" No revenue data for this period.");
255 frame.render_widget(empty, chunks[1]);
256 } else {
257 let max_val = data
258 .buckets
259 .iter()
260 .map(|b| b.revenue_cents)
261 .max()
262 .unwrap_or(1)
263 .max(1);
264
265 let bars: Vec<Bar> = data
266 .buckets
267 .iter()
268 .map(|b| {
269 Bar::default()
270 .value(b.revenue_cents as u64)
271 .label(Line::from(b.label.clone()))
272 .text_value(if b.revenue_cents > 0 {
273 format::format_cents(b.revenue_cents, app.currency())
274 } else {
275 String::new()
276 })
277 .style(Style::default().fg(app.theme.action_primary))
278 })
279 .collect();
280
281 let chart = BarChart::default()
282 .data(BarGroup::default().bars(&bars))
283 .bar_width(
284 (chunks[1].width as usize)
285 .checked_div(bars.len().max(1))
286 .unwrap_or(3)
287 .clamp(1, 8) as u16,
288 )
289 .bar_gap(1)
290 .max(max_val as u64);
291
292 frame.render_widget(chart, chunks[1]);
293 }
294 } else {
295 let empty = Paragraph::new(" No data.");
296 frame.render_widget(empty, chunks[1]);
297 }
298
299 // Projects header
300 let proj_header = Paragraph::new(Line::from(vec![
301 Span::raw(" "),
302 Span::styled(
303 "Top Projects",
304 Style::default().add_modifier(Modifier::BOLD),
305 ),
306 ]));
307 frame.render_widget(proj_header, chunks[3]);
308
309 // Top projects
310 if let Some(ref data) = app.analytics_data {
311 if data.top_projects.is_empty() {
312 let empty = Paragraph::new(" No project revenue yet.");
313 frame.render_widget(empty, chunks[4]);
314 } else {
315 let area = super::indent(chunks[4], 2);
316 let rows: Vec<Vec<Cell>> = data
317 .top_projects
318 .iter()
319 .map(|p| {
320 vec![
321 Cell::new("Project", p.title.as_str()),
322 Cell::new("Revenue", p.revenue().display_compact(app.currency())),
323 ]
324 })
325 .collect();
326
327 // Nothing selects a row here, so this one draws without a state.
328 let style = TableStyle::from_theme(&app.theme);
329 let table = table::table(
330 &TOP_PROJECT_COLUMNS,
331 &rows,
332 &TOP_PROJECT_SIZING,
333 &style,
334 area.width,
335 );
336 frame.render_widget(table, area);
337 }
338 }
339 }
340
341 fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
342 let chunks = Layout::vertical([
343 Constraint::Length(1), // header
344 Constraint::Min(3), // table
345 ])
346 .split(area);
347
348 let count = app.transactions.len();
349 let header = Paragraph::new(Line::from(vec![
350 Span::raw(" "),
351 Span::styled(
352 "Transactions",
353 Style::default().add_modifier(Modifier::BOLD),
354 ),
355 if count == 0 {
356 Span::raw("")
357 } else {
358 Span::raw(format!(" ({count})"))
359 },
360 ]));
361 frame.render_widget(header, chunks[0]);
362
363 if app.transactions.is_empty() {
364 let empty = Paragraph::new(" No transactions.");
365 frame.render_widget(empty, chunks[1]);
366 } else {
367 let area = super::indent(chunks[1], 2);
368 let rows: Vec<Vec<Cell>> = app
369 .transactions
370 .iter()
371 .map(|tx| {
372 vec![
373 Cell::new("Item", tx.item_title.as_deref().unwrap_or("--")),
374 Cell::new(
375 "Amount",
376 format::format_cents(i64::from(tx.amount_cents), app.currency()),
377 ),
378 Cell::new("Status", tx.status.as_str()),
379 Cell::new("Date", tx.created_at.get(..10).unwrap_or(&tx.created_at)),
380 ]
381 })
382 .collect();
383
384 let style = TableStyle::from_theme(&app.theme);
385 let table = table::table(
386 &TRANSACTION_COLUMNS,
387 &rows,
388 &TRANSACTION_SIZING,
389 &style,
390 area.width,
391 );
392 let mut state = TableState::default().with_selected(Some(app.selected_index));
393 frame.render_stateful_widget(table, area, &mut state);
394 }
395 }
396
397 fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
398 if previous == 0 {
399 if current > 0 {
400 return Some(("+inf%".to_string(), true));
401 }
402 return None;
403 }
404 let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64;
405 if change == 0 {
406 None
407 } else {
408 let text = if change > 0 {
409 format!("+{change}%")
410 } else {
411 format!("{change}%")
412 };
413 Some((text, change > 0))
414 }
415 }
416