Skip to main content

max / makenotwork

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