Skip to main content

max / makenotwork

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