Skip to main content

max / makenotwork

22.2 KB · 629 lines History Blame Raw
1 //! The user-level analytics tab, described.
2 //!
3 //! S4's third batch, and the first screen that is not a table with a heading on
4 //! it. Five figures with deltas, a range selector, a bar chart, a comparison
5 //! table and a list, which is why it was taken: it is where the vocabulary
6 //! stops covering the dashboard, and the only way to find that out is to
7 //! convert one.
8 //!
9 //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_analytics`,
10 //! which answers the same address from Askama when the screen is switched off.
11 //!
12 //! # What it found, and what each answer cost
13 //!
14 //! Three gaps, and the count decided all three differently. That is the method
15 //! working rather than three separate judgement calls.
16 //!
17 //! 1. **A figure had no delta.** Four screens here put a label, a value and a
18 //! change in one stat card, and the change is the toned part while the
19 //! number is an ordinary fact, so `Figure::tone` had no consumer at all. Four
20 //! sites is below the 30-to-53 the earlier table members cleared, and it
21 //! landed anyway because the alternative was folding the delta into the
22 //! caption, which loses the tone and turns a small second line into a longer
23 //! first one. makeover-layout 0.13.0, makeover-webview 0.24.0.
24 //! 2. **A bar chart is not describable, and should not be.** makeover-layout
25 //! names no chart, and the admission test is that a node composes something
26 //! it already names. Inventing one there would be the layer naming a widget.
27 //! So the chart is a [`quasi_router::RegionKind::Bespoke`] fill, which is
28 //! exactly what bespoke regions are for, and it is the first one in the
29 //! tree. See [`chart_markup`].
30 //! 3. **A proportion bar inside a table cell is one site.** The comparison
31 //! table's revenue cell draws a bar behind the number. One site across every
32 //! template, so it does not earn a `Cell::meter` and the cell is the number
33 //! alone. **That is a visible loss** and it is recorded rather than hidden:
34 //! the reader keeps every figure and loses the at-a-glance comparison
35 //! between rows. If a second site appears, the member is earned and this
36 //! comes back.
37 //!
38 //! # The range selector is four chips, not a segmented control
39 //!
40 //! Nothing names a segmented control and it does not need to: four chips, one
41 //! latched, is what the affordance is, and `latched` is already makeover's word
42 //! for a held-down chip. Each carries the range it selects, so the address a
43 //! reader lands on is the view they are looking at.
44
45 use std::fmt::Write as _;
46
47 use makeover_layout as layout;
48 use quasi_router::screen::{Cell, Cells, Column, Figure, Row, Tag};
49 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
50 use quasi_webview::{Shell, Webview};
51
52 use super::Viewer;
53 use crate::db;
54
55 /// The conversion switch's name for this screen. `QUASI_SCREENS=user_analytics`.
56 pub const SCREEN: &str = "user_analytics";
57
58 /// The address this screen answers, and the one the Askama route gives up.
59 pub const PATH: &str = "/dashboard/tabs/analytics";
60
61 /// The region the answer replaces: the pane the dashboard's tab nav targets.
62 const REGION: &str = "tab-content";
63
64 /// The slot the chart's own markup mounts into.
65 const CHART_SLOT: &str = "analytics-chart";
66
67 /// The ranges offered, in the order the selector draws them.
68 const RANGES: [(&str, &str); 4] = [
69 ("7d", "Last 7 days"),
70 ("30d", "Last 30 days"),
71 ("90d", "Last 90 days"),
72 ("all", "All time"),
73 ];
74
75 /// One stat card, as the screen needs it.
76 pub struct StatView {
77 label: String,
78 value: String,
79 change: Option<String>,
80 positive: bool,
81 }
82
83 /// One bar of the revenue chart.
84 pub struct BarView {
85 label: String,
86 value: String,
87 count: i64,
88 height_pct: f64,
89 }
90
91 /// One project's row in the comparison table.
92 pub struct ProjectView {
93 title: String,
94 revenue: String,
95 sales: String,
96 views: String,
97 conversion: String,
98 }
99
100 /// One project's line in the all-time revenue list.
101 pub struct TotalView {
102 title: String,
103 revenue: String,
104 }
105
106 /// Everything one answer needs, so the assembly can be tested without a
107 /// database.
108 pub struct Analytics {
109 range: String,
110 stats: Vec<StatView>,
111 bars: Vec<BarView>,
112 projects: Vec<ProjectView>,
113 totals: Vec<TotalView>,
114 }
115
116 /// The tab.
117 pub fn screen(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
118 // The selector's chips carry the range they select, and a read's values
119 // land in `carried`. An unknown or absent one falls back the way the Askama
120 // handler's `parse().ok().unwrap_or` does rather than refusing: a range is
121 // a view, and a bad view is not an error worth a page.
122 // Taken by value because the handler signature is quasi's, so the request is
123 // consumed here rather than borrowed from.
124 let carried = request.carried;
125 let range = carried
126 .get("range")
127 .and_then(|r| r.parse::<db::analytics::TimeRange>().ok())
128 .unwrap_or(db::analytics::TimeRange::Days30);
129
130 let analytics = read(viewer, &range)?;
131
132 // Handed to the renderer through the state both of them see. The chart is
133 // the app's own markup and there is no node for it; see the module header.
134 viewer.fill(CHART_SLOT, chart_markup(&analytics.bars));
135
136 Ok(Response::fragment(REGION, pane(&analytics)))
137 }
138
139 /// Everything the screen reads, in the order the Askama handler reads it.
140 fn read(viewer: &Viewer, range: &db::analytics::TimeRange) -> Result<Analytics, RouteError> {
141 let db = &viewer.app.db;
142 let user_id = viewer.user.id;
143 let currency = viewer.user.settlement_currency;
144 let failed = |_| RouteError::internal("your analytics could not be read");
145
146 let buckets = viewer
147 .block_on(db::analytics::get_revenue_timeseries(
148 db, user_id, None, None, range,
149 ))
150 .map_err(failed)?;
151 let comparison = viewer
152 .block_on(db::analytics::get_period_comparison(
153 db, user_id, None, None, range,
154 ))
155 .map_err(failed)?;
156 let (current_views, prev_views) = viewer
157 .block_on(db::page_views::get_view_period_comparison(
158 db, user_id, None, range,
159 ))
160 .map_err(failed)?;
161
162 let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency)
163 .into_iter()
164 .map(|bar| BarView {
165 label: bar.label,
166 value: bar.value,
167 count: bar.count,
168 height_pct: bar.height_pct,
169 })
170 .collect();
171
172 let view_change = db::analytics::pct_change(current_views, prev_views);
173 let mut stats = vec![
174 StatView {
175 label: "Views".into(),
176 value: current_views.to_string(),
177 change: view_change.as_ref().map(|(text, _)| text.clone()),
178 positive: view_change.is_none_or(|(_, up)| up),
179 },
180 StatView {
181 label: "Revenue".into(),
182 value: crate::formatting::format_revenue(
183 comparison.current_revenue_cents.as_i64(),
184 currency,
185 ),
186 change: comparison.revenue_change().map(|(text, _)| text),
187 positive: comparison.revenue_change().is_none_or(|(_, up)| up),
188 },
189 StatView {
190 label: "Sales".into(),
191 value: comparison.current_sales.to_string(),
192 change: comparison.sales_change().map(|(text, _)| text),
193 positive: comparison.sales_change().is_none_or(|(_, up)| up),
194 },
195 StatView {
196 label: "Followers".into(),
197 value: comparison.current_followers.to_string(),
198 change: comparison.followers_change().map(|(text, _)| text),
199 positive: comparison.followers_change().is_none_or(|(_, up)| up),
200 },
201 ];
202 // Conversion needs a denominator. The Askama version omits the card rather
203 // than showing a dash where a percentage goes.
204 if current_views > 0 {
205 stats.push(StatView {
206 label: "Conversion".into(),
207 value: format!(
208 "{:.1}%",
209 comparison.current_sales as f64 / current_views as f64 * 100.0
210 ),
211 change: None,
212 positive: true,
213 });
214 }
215
216 let project_data = viewer
217 .block_on(db::transactions::get_revenue_by_user_projects_in_range(
218 db, user_id, range,
219 ))
220 .map_err(failed)?;
221 let project_views = viewer
222 .block_on(db::page_views::get_views_by_seller_projects(
223 db, user_id, range,
224 ))
225 .map_err(failed)?;
226 let projects = project_data
227 .iter()
228 .map(|(id, title, revenue, sales)| {
229 let views = project_views
230 .iter()
231 .find(|(other, _)| other == id)
232 .map_or(0, |(_, seen)| *seen);
233 ProjectView {
234 title: title.clone(),
235 revenue: revenue.display(currency),
236 sales: sales.to_string(),
237 views: views.to_string(),
238 conversion: if views > 0 {
239 format!("{:.1}%", *sales as f64 / views as f64 * 100.0)
240 } else {
241 "-".to_owned()
242 },
243 }
244 })
245 .collect();
246
247 let totals = viewer
248 .block_on(db::transactions::get_revenue_by_user_projects(db, user_id))
249 .map_err(failed)?
250 .into_iter()
251 .map(|(_, title, revenue)| TotalView {
252 title,
253 revenue: revenue.display(currency),
254 })
255 .collect();
256
257 Ok(Analytics {
258 range: range.to_string(),
259 stats,
260 bars,
261 projects,
262 totals,
263 })
264 }
265
266 /// Everything inside the tab pane.
267 fn pane(analytics: &Analytics) -> Node {
268 let mut slot =
269 Slot::new(REGION, RegionKind::Pane).with(Node::section(range_heading(&analytics.range)));
270
271 for chip in range_chips(&analytics.range) {
272 slot = slot.with(chip);
273 }
274
275 slot = slot.with(stats(&analytics.stats));
276
277 slot = slot.with(Node::section("Revenue Over Time"));
278 slot = if analytics.bars.is_empty() {
279 slot.with(Node::empty(
280 "Once you publish items and make sales, revenue data will appear here.",
281 ))
282 } else {
283 // The chart's own markup arrives through the renderer. The description
284 // says only that there is a region here and what it is called.
285 slot.with(Node::Region(Slot::bespoke(CHART_SLOT, "revenue-chart")))
286 };
287
288 // One project is not a comparison, which is the condition the template
289 // wraps this whole section in.
290 if analytics.projects.len() > 1 {
291 slot = slot
292 .with(Node::section("Project Comparison"))
293 .with(comparison(&analytics.projects));
294 }
295
296 slot = slot.with(Node::section("Top Projects by Revenue"));
297 slot = if analytics.totals.is_empty() {
298 slot.with(Node::empty(
299 "No revenue data yet. Sales across your projects will appear here.",
300 ))
301 } else {
302 slot.with(totals(&analytics.totals))
303 };
304
305 Node::Region(slot)
306 }
307
308 /// What the current range is called.
309 fn range_heading(range: &str) -> &'static str {
310 RANGES
311 .iter()
312 .find(|(value, _)| *value == range)
313 .map_or("All time", |(_, name)| *name)
314 }
315
316 /// The range selector: one chip per range, the current one held down.
317 fn range_chips(range: &str) -> Vec<Node> {
318 RANGES
319 .iter()
320 .map(|(value, _)| {
321 Node::Token(
322 Tag::chip(*value, Action::get(PATH).carrying("range", *value))
323 .latched(*value == range),
324 )
325 })
326 .collect()
327 }
328
329 /// The figures across the top.
330 fn stats(stats: &[StatView]) -> Node {
331 Node::Stats {
332 figures: stats
333 .iter()
334 .map(|stat| {
335 let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
336 // The tone rides on the delta, which is why a card without one
337 // stays neutral rather than being coloured green for having
338 // nothing to report. `positive` is `true` by default in the
339 // source data, so toning on it alone would paint every
340 // unchanged card.
341 if let Some(change) = &stat.change {
342 figure = figure.change(change.clone()).tone(if stat.positive {
343 layout::Tone::Success
344 } else {
345 layout::Tone::Danger
346 });
347 }
348 (figure, None)
349 })
350 .collect(),
351 }
352 }
353
354 /// The per-project comparison.
355 fn comparison(projects: &[ProjectView]) -> Node {
356 Node::Table {
357 columns: vec![
358 Column::new("Project")
359 .width(layout::Width::Fill)
360 .priority(layout::Priority::Essential),
361 Column::new("Revenue")
362 .width(layout::Width::Content)
363 .priority(layout::Priority::Essential),
364 Column::new("Sales").width(layout::Width::Content),
365 Column::new("Views").width(layout::Width::Content),
366 Column::new("Conversion")
367 .width(layout::Width::Content)
368 .priority(layout::Priority::Optional),
369 ],
370 rows: projects
371 .iter()
372 .map(|project| {
373 Cells::new([
374 Cell::new(project.title.clone()),
375 // The number alone. The template draws a bar behind it
376 // scaled against the biggest earner, and that is one site
377 // in the whole template set, so it does not earn a member.
378 // See the module header.
379 Cell::new(project.revenue.clone()),
380 Cell::new(project.sales.clone()),
381 Cell::new(project.views.clone()),
382 Cell::new(project.conversion.clone()),
383 ])
384 })
385 .collect(),
386 }
387 }
388
389 /// All-time revenue per project.
390 fn totals(totals: &[TotalView]) -> Node {
391 Node::List {
392 rows: totals
393 .iter()
394 .map(|total| Row::new(total.title.clone()).meta(total.revenue.clone()))
395 .collect(),
396 more: None,
397 }
398 }
399
400 /// The chart, as markup, because no description names one.
401 ///
402 /// Byte-for-byte the structure `templates/partials/chart_bars.html` emits, so
403 /// the existing `.chart-*` rules in `style.css` draw it unchanged and the
404 /// described screen and the Askama one are the same chart rather than two that
405 /// drifted.
406 ///
407 /// Everything interpolated here is escaped. A bespoke region is not escaped by
408 /// the renderer, which is what makes it bespoke, so the escaping is this
409 /// function's job and a label reaching it from a database is exactly why.
410 fn chart_markup(bars: &[BarView]) -> String {
411 let mut html = String::from("<div class=\"chart-bars\">");
412 for bar in bars {
413 let plural = if bar.count == 1 { "" } else { "s" };
414 let _ = write!(
415 html,
416 "<div class=\"chart-bar-col\" data-tooltip=\"{} / {} sale{plural}\">\
417 <div class=\"chart-bar\" style=\"--fill: {}%;\"></div>\
418 <div class=\"chart-bar-label\">{}</div></div>",
419 escape(&bar.value),
420 bar.count,
421 // A float straight from the database, so it is formatted rather
422 // than printed: `{:?}` on an f64 can emit an exponent, and
423 // `--fill: 1e-7%` is not a length any browser accepts.
424 format_args!("{:.4}", bar.height_pct),
425 escape(&bar.label),
426 );
427 }
428 html.push_str("</div>");
429 html
430 }
431
432 /// The five characters that matter in markup and in an attribute value.
433 fn escape(text: &str) -> String {
434 text.replace('&', "&amp;")
435 .replace('<', "&lt;")
436 .replace('>', "&gt;")
437 .replace('"', "&quot;")
438 .replace('\'', "&#39;")
439 }
440
441 /// The renderer this screen is drawn with.
442 ///
443 /// Mounts whatever the handler drew for its bespoke regions. The two see one
444 /// `Viewer`; see [`super::Viewer::fills`].
445 pub fn renderer(viewer: &Viewer) -> Webview {
446 let mut webview = Webview::new().with_shell(Shell::under("/static").layered([
447 "base",
448 "components",
449 "responsive",
450 ]));
451 for (slot, markup) in viewer.drawn() {
452 webview = webview.with_fill(slot, markup);
453 }
454 webview
455 }
456
457 #[cfg(test)]
458 mod tests {
459 use super::*;
460 use quasi_axum::Serves;
461
462 fn analytics() -> Analytics {
463 Analytics {
464 range: "30d".into(),
465 stats: vec![
466 StatView {
467 label: "Views".into(),
468 value: "1,204".into(),
469 change: Some("+12.5%".into()),
470 positive: true,
471 },
472 StatView {
473 label: "Conversion".into(),
474 value: "3.1%".into(),
475 change: None,
476 positive: true,
477 },
478 ],
479 bars: vec![BarView {
480 label: "Aug 1".into(),
481 value: "$42.00".into(),
482 count: 3,
483 height_pct: 62.5,
484 }],
485 projects: vec![
486 ProjectView {
487 title: "Atlas".into(),
488 revenue: "$120.00".into(),
489 sales: "4".into(),
490 views: "300".into(),
491 conversion: "1.3%".into(),
492 },
493 ProjectView {
494 title: "Beacon".into(),
495 revenue: "$60.00".into(),
496 sales: "2".into(),
497 views: "150".into(),
498 conversion: "1.3%".into(),
499 },
500 ],
501 totals: vec![TotalView {
502 title: "Atlas".into(),
503 revenue: "$980.00".into(),
504 }],
505 }
506 }
507
508 fn render(node: &Node) -> String {
509 Webview::new().fragment(node)
510 }
511
512 #[test]
513 fn the_region_matches_what_the_tab_nav_targets() {
514 let nav = include_str!("../../templates/partials/tabs/user_analytics.html");
515 assert!(nav.contains(&format!("hx-target=\"#{REGION}\"")));
516 assert!(nav.contains(&format!("hx-get=\"{PATH}?range=7d\"")));
517 }
518
519 #[test]
520 fn exactly_one_range_is_held_down_and_each_carries_its_own() {
521 let html = render(&Node::Region(
522 range_chips("90d")
523 .into_iter()
524 .fold(Slot::new(REGION, RegionKind::Pane), Slot::with),
525 ));
526
527 assert_eq!(html.matches("latched").count(), 1, "{html}");
528 for range in ["7d", "30d", "90d", "all"] {
529 assert!(
530 html.contains(&format!("range={range}")),
531 "{range} is offered: {html}"
532 );
533 }
534 // An unknown range falls back rather than leaving nothing selected, so
535 // the heading and the selector cannot disagree about where the reader is.
536 assert_eq!(range_heading("nonsense"), "All time");
537 assert_eq!(range_heading("7d"), "Last 7 days");
538 }
539
540 #[test]
541 fn a_delta_is_toned_and_a_card_without_one_is_not() {
542 // makeover-layout 0.13.0's whole point. `positive` is true by default in
543 // the source data, so toning on it alone would paint every card that has
544 // nothing to report.
545 let html = render(&stats(&analytics().stats));
546
547 assert!(html.contains("+12.5%"), "{html}");
548 assert_eq!(
549 html.matches("data-tone").count(),
550 1,
551 "one card is toned: {html}"
552 );
553 assert!(html.contains("data-tone=\"success\""), "{html}");
554 assert!(
555 html.contains("3.1%"),
556 "the untoned card is still there: {html}"
557 );
558 }
559
560 #[test]
561 fn the_chart_is_the_markup_the_template_already_emits() {
562 // The described screen and the Askama one draw one chart, against one
563 // set of `.chart-*` rules. If this structure drifts the two diverge
564 // silently, because nothing else renders it.
565 let html = chart_markup(&analytics().bars);
566
567 assert!(html.contains("class=\"chart-bars\""), "{html}");
568 assert!(html.contains("class=\"chart-bar-col\""), "{html}");
569 assert!(html.contains("--fill: 62.5000%"), "{html}");
570 assert!(html.contains("3 sales"), "{html}");
571
572 // The template's own pluralisation, which a described copy is easy to
573 // get wrong in exactly one direction.
574 let one = chart_markup(&[BarView {
575 label: "Aug 2".into(),
576 count: 1,
577 ..analytics().bars.pop().expect("one bar")
578 }]);
579 assert!(one.contains("1 sale ") || one.contains("1 sale\""), "{one}");
580 }
581
582 #[test]
583 fn a_bespoke_fill_is_the_apps_markup_and_still_escapes_its_data() {
584 // A bespoke region is not escaped by the renderer, which is the whole
585 // of what makes it bespoke. A bar's label is a formatted date today and
586 // its value comes from the database, so the escaping is this screen's
587 // job and nothing else will do it.
588 let html = chart_markup(&[BarView {
589 label: "<script>x()</script>".into(),
590 value: "\" onload=\"x()".into(),
591 count: 1,
592 height_pct: 10.0,
593 }]);
594
595 assert!(!html.contains("<script>x()"), "{html}");
596 assert!(!html.contains("\" onload="), "{html}");
597 }
598
599 #[test]
600 fn the_comparison_only_draws_when_there_is_something_to_compare() {
601 let mut one = analytics();
602 one.projects.truncate(1);
603 let html = render(&pane(&one));
604 assert!(!html.contains("Project Comparison"), "{html}");
605
606 let both = render(&pane(&analytics()));
607 assert!(both.contains("Project Comparison"), "{both}");
608 assert!(both.contains("Atlas") && both.contains("Beacon"), "{both}");
609 }
610
611 #[test]
612 fn an_account_with_no_history_says_so_in_both_places() {
613 let empty = Analytics {
614 range: "30d".into(),
615 stats: vec![],
616 bars: vec![],
617 projects: vec![],
618 totals: vec![],
619 };
620 let html = render(&pane(&empty));
621
622 assert!(html.contains("revenue data will appear here."), "{html}");
623 assert!(html.contains("Sales across your projects"), "{html}");
624 // And the chart region is absent rather than empty, so the stand-in is
625 // not sitting next to a blank chart.
626 assert!(!html.contains(CHART_SLOT), "{html}");
627 }
628 }
629