Skip to main content

max / makenotwork

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