Skip to main content

max / makenotwork

10.5 KB · 336 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(data.as_ref().map_or(0, |d| d.current_revenue_cents)),
113 data.as_ref()
114 .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
115 ),
116 (
117 "Sales",
118 data.as_ref()
119 .map_or_else(|| "--".into(), |d| d.current_sales.to_string()),
120 data.as_ref()
121 .map(|d| pct_change(d.current_sales, d.previous_sales)),
122 ),
123 (
124 "Followers",
125 data.as_ref()
126 .map_or_else(|| "--".into(), |d| d.current_followers.to_string()),
127 data.as_ref()
128 .map(|d| pct_change(d.current_followers, d.previous_followers)),
129 ),
130 ];
131
132 for (i, (label, value, change)) in stats.iter().enumerate() {
133 let block = Block::default()
134 .borders(Borders::ALL)
135 .border_style(Style::default().fg(Color::DarkGray));
136 let inner = block.inner(stat_chunks[i]);
137 frame.render_widget(block, stat_chunks[i]);
138
139 let mut spans = vec![
140 Span::styled(
141 format!(" {value}"),
142 Style::default().add_modifier(Modifier::BOLD),
143 ),
144 Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
145 ];
146
147 if let Some(Some((text, positive))) = change {
148 let color = if *positive { Color::Green } else { Color::Red };
149 spans.push(Span::styled(format!(" {text}"), Style::default().fg(color)));
150 }
151
152 let text = Paragraph::new(Line::from(spans));
153 frame.render_widget(text, inner);
154 }
155 }
156
157 fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
158 let chunks = Layout::vertical([
159 Constraint::Length(1), // chart header
160 Constraint::Min(4), // chart
161 Constraint::Length(1), // spacer
162 Constraint::Length(1), // projects header
163 Constraint::Min(2), // projects table
164 ])
165 .split(area);
166
167 // Chart header
168 let header = Paragraph::new(Line::from(vec![
169 Span::raw(" "),
170 Span::styled("Revenue", Style::default().add_modifier(Modifier::BOLD)),
171 ]));
172 frame.render_widget(header, chunks[0]);
173
174 // Revenue bar chart
175 if let Some(ref data) = app.analytics_data {
176 if data.buckets.is_empty() {
177 let empty = Paragraph::new(" No revenue data for this period.");
178 frame.render_widget(empty, chunks[1]);
179 } else {
180 let max_val = data
181 .buckets
182 .iter()
183 .map(|b| b.revenue_cents)
184 .max()
185 .unwrap_or(1)
186 .max(1);
187
188 let bars: Vec<Bar> = data
189 .buckets
190 .iter()
191 .map(|b| {
192 Bar::default()
193 .value(b.revenue_cents as u64)
194 .label(Line::from(b.label.clone()))
195 .text_value(if b.revenue_cents > 0 {
196 format::format_cents(b.revenue_cents)
197 } else {
198 String::new()
199 })
200 .style(Style::default().fg(Color::Cyan))
201 })
202 .collect();
203
204 let chart = BarChart::default()
205 .data(BarGroup::default().bars(&bars))
206 .bar_width(
207 (chunks[1].width as usize)
208 .checked_div(bars.len().max(1))
209 .unwrap_or(3)
210 .clamp(1, 8) as u16,
211 )
212 .bar_gap(1)
213 .max(max_val as u64);
214
215 frame.render_widget(chart, chunks[1]);
216 }
217 } else {
218 let empty = Paragraph::new(" No data.");
219 frame.render_widget(empty, chunks[1]);
220 }
221
222 // Projects header
223 let proj_header = Paragraph::new(Line::from(vec![
224 Span::raw(" "),
225 Span::styled(
226 "Top Projects",
227 Style::default().add_modifier(Modifier::BOLD),
228 ),
229 ]));
230 frame.render_widget(proj_header, chunks[3]);
231
232 // Top projects
233 if let Some(ref data) = app.analytics_data {
234 if data.top_projects.is_empty() {
235 let empty = Paragraph::new(" No project revenue yet.");
236 frame.render_widget(empty, chunks[4]);
237 } else {
238 let rows: Vec<Row> = data
239 .top_projects
240 .iter()
241 .map(|p| {
242 Row::new(vec![
243 format!(" {}", p.title),
244 format::format_cents(p.revenue_cents),
245 ])
246 })
247 .collect();
248
249 let widths = [Constraint::Min(20), Constraint::Length(12)];
250 widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows);
251 }
252 }
253 }
254
255 fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
256 let chunks = Layout::vertical([
257 Constraint::Length(1), // header
258 Constraint::Min(3), // table
259 ])
260 .split(area);
261
262 let count = app.transactions.len();
263 let header = Paragraph::new(Line::from(vec![
264 Span::raw(" "),
265 Span::styled(
266 "Transactions",
267 Style::default().add_modifier(Modifier::BOLD),
268 ),
269 if count == 0 {
270 Span::raw("")
271 } else {
272 Span::raw(format!(" ({count})"))
273 },
274 ]));
275 frame.render_widget(header, chunks[0]);
276
277 if app.transactions.is_empty() {
278 let empty = Paragraph::new(" No transactions.");
279 frame.render_widget(empty, chunks[1]);
280 } else {
281 let rows: Vec<Row> = app
282 .transactions
283 .iter()
284 .enumerate()
285 .map(|(i, tx)| {
286 let title = tx.item_title.as_deref().unwrap_or("--");
287 let amount = format::format_cents(tx.amount_cents as i64);
288 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
289
290 Row::new(vec![
291 format!(" {}", title),
292 amount,
293 tx.status.clone(),
294 date.to_string(),
295 ])
296 .style(widgets::selected_style(i, Some(app.selected_index)))
297 })
298 .collect();
299
300 let widths = [
301 Constraint::Min(20),
302 Constraint::Length(12),
303 Constraint::Length(10),
304 Constraint::Length(12),
305 ];
306
307 widgets::render_table(
308 frame,
309 chunks[1],
310 &[" Item", "Amount", "Status", "Date"],
311 &widths,
312 rows,
313 );
314 }
315 }
316
317 fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
318 if previous == 0 {
319 if current > 0 {
320 return Some(("+inf%".to_string(), true));
321 }
322 return None;
323 }
324 let change = ((current - previous) as f64 / previous as f64 * 100.0).round() as i64;
325 if change == 0 {
326 None
327 } else {
328 let text = if change > 0 {
329 format!("+{change}%")
330 } else {
331 format!("{change}%")
332 };
333 Some((text, change > 0))
334 }
335 }
336