Skip to main content

max / makenotwork

24.3 KB · 665 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 describable, and this screen is why.** It was not, for a
25 //! while: makeover-layout named no chart, and the admission test is that a
26 //! node composes something it already names, so the chart was a
27 //! `RegionKind::Ceded` fill -- the first and last one in the tree. That put
28 //! this screen alone off the compiled-template seam, because a ceded
29 //! region's markup is looked up WHILE the renderer renders and a residual
30 //! has nowhere to keep it. Max ruled on 2026-09-08 that a bespoke region is
31 //! the mark of a screen the description layer has not finished converting,
32 //! so the chart was described instead: `makeover_layout::Chart` and `Bar`,
33 //! drawn by each renderer. quasicoherent `7d6ad166`.
34 //! 3. **A proportion bar inside a table cell is one site.** The comparison
35 //! table's revenue cell draws a bar behind the number. One site across every
36 //! template, so it does not earn a `Cell::meter` and the cell is the number
37 //! alone. **That is a visible loss** and it is recorded rather than hidden:
38 //! the reader keeps every figure and loses the at-a-glance comparison
39 //! between rows. If a second site appears, the member is earned and this
40 //! comes back.
41 //!
42 //! # The range selector is four chips, not a segmented control
43 //!
44 //! Nothing names a segmented control and it does not need to: four chips, one
45 //! latched, is what the affordance is, and `latched` is already makeover's word
46 //! for a held-down chip. Each carries the range it selects, so the address a
47 //! reader lands on is the view they are looking at.
48
49 use makeover_layout as layout;
50 use quasi_declare::declare;
51 use quasi_router::screen::{Bar, Chart, Figure};
52 use quasi_router::{Request, Response, RouteError};
53 use quasi_webview::Webview;
54
55 use super::Viewer;
56 use crate::db;
57
58 /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26`
59 /// deleted the flag; it survives as the marker the tab strips read.
60 pub const SCREEN: &str = "user_analytics";
61
62 /// The address this screen answers, and the one the Askama route gives up.
63 pub const PATH: &str = "/dashboard/tabs/analytics";
64
65 /// The region the answer replaces: this screen's own frame in the dashboard's
66 /// described tab strip.
67 ///
68 /// It said `tab-content`, the single pane the hand-written strip swapped into.
69 /// Under `super::user_tabs` that id is the strip itself and each panel is its
70 /// own frame, so this moved to the one that is this screen's -- the same move
71 /// `ssh_keys::REGION` made in step 4. `user_tabs` draws its frame from this
72 /// constant and a test there asserts the two agree.
73 pub const REGION: &str = "user-analytics";
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 project's row in the comparison table.
84 pub struct ProjectView {
85 title: String,
86 revenue: String,
87 sales: String,
88 views: String,
89 conversion: String,
90 }
91
92 /// One project's line in the all-time revenue list.
93 pub struct TotalView {
94 title: String,
95 revenue: String,
96 }
97
98 /// Everything one answer needs, so the assembly can be tested without a
99 /// database.
100 pub struct Analytics {
101 range: String,
102 stats: Vec<StatView>,
103 chart: crate::types::RevenueChart,
104 projects: Vec<ProjectView>,
105 totals: Vec<TotalView>,
106 }
107
108 /// The tab.
109 pub fn screen(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
110 // The selector's chips carry the range they select, and a read's values
111 // land in `carried`. An unknown or absent one falls back the way the Askama
112 // handler's `parse().ok().unwrap_or` does rather than refusing: a range is
113 // a view, and a bad view is not an error worth a page.
114 // Taken by value because the handler signature is quasi's, so the request is
115 // consumed here rather than borrowed from.
116 let carried = request.carried;
117 let range = carried
118 .get("range")
119 .and_then(|r| r.parse::<db::analytics::TimeRange>().ok())
120 .unwrap_or(db::analytics::TimeRange::Days30);
121
122 let analytics = read(viewer, &range)?;
123
124 Ok(Response::fragment(REGION, pane(&analytics)))
125 }
126
127 /// Everything the screen reads, in the order the Askama handler reads it.
128 fn read(viewer: &Viewer, range: &db::analytics::TimeRange) -> Result<Analytics, RouteError> {
129 let db = &viewer.app.db;
130 let user_id = viewer.reader()?.id;
131 let currency = viewer.reader()?.settlement_currency;
132 let failed = |_| RouteError::internal("your analytics could not be read");
133
134 let buckets = viewer
135 .block_on(db::analytics::get_revenue_timeseries(
136 db, user_id, None, None, range,
137 ))
138 .map_err(failed)?;
139 let comparison = viewer
140 .block_on(db::analytics::get_period_comparison(
141 db, user_id, None, None, range,
142 ))
143 .map_err(failed)?;
144 let (current_views, prev_views) = viewer
145 .block_on(db::page_views::get_view_period_comparison(
146 db, user_id, None, range,
147 ))
148 .map_err(failed)?;
149
150 // `build_revenue_chart` already produces exactly what the chart draws. This
151 // remapped it into a private `BarView` with the same fields, which meant
152 // `project_analytics` could not reuse the chart without a third copy of the
153 // type. Dropped 2026-08-26 when that screen converted.
154 let chart = crate::routes::pages::dashboard::build_revenue_chart(&buckets, currency);
155
156 let view_change = db::analytics::pct_change(current_views, prev_views);
157 let mut stats = vec![
158 StatView {
159 label: "Views".into(),
160 value: current_views.to_string(),
161 change: view_change.as_ref().map(|(text, _)| text.clone()),
162 positive: view_change.is_none_or(|(_, up)| up),
163 },
164 StatView {
165 label: "Revenue".into(),
166 value: crate::formatting::format_revenue(
167 comparison.current_revenue_cents.as_i64(),
168 currency,
169 ),
170 change: comparison.revenue_change().map(|(text, _)| text),
171 positive: comparison.revenue_change().is_none_or(|(_, up)| up),
172 },
173 StatView {
174 label: "Sales".into(),
175 value: comparison.current_sales.to_string(),
176 change: comparison.sales_change().map(|(text, _)| text),
177 positive: comparison.sales_change().is_none_or(|(_, up)| up),
178 },
179 StatView {
180 label: "Followers".into(),
181 value: comparison.current_followers.to_string(),
182 change: comparison.followers_change().map(|(text, _)| text),
183 positive: comparison.followers_change().is_none_or(|(_, up)| up),
184 },
185 ];
186 // Conversion needs a denominator. The Askama version omits the card rather
187 // than showing a dash where a percentage goes.
188 if current_views > 0 {
189 stats.push(StatView {
190 label: "Conversion".into(),
191 value: format!(
192 "{:.1}%",
193 comparison.current_sales as f64 / current_views as f64 * 100.0
194 ),
195 change: None,
196 positive: true,
197 });
198 }
199
200 let project_data = viewer
201 .block_on(db::transactions::get_revenue_by_user_projects_in_range(
202 db, user_id, range,
203 ))
204 .map_err(failed)?;
205 let project_views = viewer
206 .block_on(db::page_views::get_views_by_seller_projects(
207 db, user_id, range,
208 ))
209 .map_err(failed)?;
210 let projects = project_data
211 .iter()
212 .map(|(id, title, revenue, sales)| {
213 let views = project_views
214 .iter()
215 .find(|(other, _)| other == id)
216 .map_or(0, |(_, seen)| *seen);
217 ProjectView {
218 title: title.clone(),
219 revenue: revenue.display(currency),
220 sales: sales.to_string(),
221 views: views.to_string(),
222 conversion: if views > 0 {
223 format!("{:.1}%", *sales as f64 / views as f64 * 100.0)
224 } else {
225 "-".to_owned()
226 },
227 }
228 })
229 .collect();
230
231 let totals = viewer
232 .block_on(db::transactions::get_revenue_by_user_projects(db, user_id))
233 .map_err(failed)?
234 .into_iter()
235 .map(|(_, title, revenue)| TotalView {
236 title,
237 revenue: revenue.display(currency),
238 })
239 .collect();
240
241 Ok(Analytics {
242 range: range.to_string(),
243 stats,
244 chart,
245 projects,
246 totals,
247 })
248 }
249
250 declare! {
251 /// Everything inside the tab pane.
252 ///
253 /// One project is not a comparison, which is why that section carries the
254 /// same guard twice: the heading and the table are two members and both are
255 /// absent together.
256 #[staged]
257 pub(crate) shape pane(analytics: &Analytics) -> Node;
258
259 region REGION as Pane {
260 section super::range_heading(&analytics.range);
261
262 include each range_chips(&analytics.range);
263
264 include stats(&analytics.stats);
265
266 section "Revenue Over Time";
267 empty "Once you publish items and make sales, revenue data will appear here."
268 when analytics.chart.bars.is_empty();
269 chart Chart::new(analytics.chart.most).label("revenue over time") unless analytics.chart.bars.is_empty() {
270 for bar in analytics.chart.bars.iter() {
271 bar Bar::at(bar.label.clone())
272 .of(bar.cents)
273 .reading(bar.value.clone())
274 .note(sales(bar.count));
275 }
276 }
277
278 section "Project Comparison" when analytics.projects.len() over 1;
279 include comparison(&analytics.projects) when analytics.projects.len() over 1;
280
281 section "Top Projects by Revenue";
282 empty "No revenue data yet. Sales across your projects will appear here."
283 when analytics.totals.is_empty();
284 include totals(&analytics.totals) unless analytics.totals.is_empty();
285 }
286 }
287
288 declare! {
289 /// The range selector: one chip per range, the current one held down.
290 #[staged]
291 shape range_chips(range: &str) -> Vec<Node>;
292
293 for window in super::RANGES {
294 chip window.value to get PATH carrying "range" window.value {
295 latched when super::is_shown(window, range);
296 }
297 }
298 }
299
300 /// The delta a card reports, or nothing.
301 fn change(stat: &StatView) -> &str {
302 stat.change.as_deref().unwrap_or_default()
303 }
304
305 declare! {
306 /// The figures across the top.
307 ///
308 /// The empty list is what the figures accrete onto: `Node::stats` takes the
309 /// whole list and this one is built a card at a time.
310 #[staged]
311 shape stats(stats: &[StatView]) -> Node;
312
313 stats [] {
314 for stat in stats.iter() {
315 // Three, one per tone a delta can carry, because a tone is not a
316 // value a residual can hold: `Tone` has no stand-in, so a supplier
317 // answering one hands the derivation a sentinel where an enum
318 // belongs. Written out, each tone is a path the derivation bakes and
319 // the guards are what a request picks between. `symbolic::PLACED`
320 // names this site.
321 figure Figure::new(stat.value.clone(), stat.label.clone())
322 when stat.change.is_none();
323 figure Figure::new(stat.value.clone(), stat.label.clone())
324 .change(change(stat))
325 .tone(layout::Tone::Success)
326 when stat.change.is_some() and stat.positive;
327 figure Figure::new(stat.value.clone(), stat.label.clone())
328 .change(change(stat))
329 .tone(layout::Tone::Danger)
330 when stat.change.is_some() and not stat.positive;
331 }
332 }
333 }
334
335 declare! {
336 /// The per-project comparison.
337 ///
338 /// The cells are positional because the column list is a few lines above
339 /// them and every project fills all five: an empty conversion is the string
340 /// "-" rather than a missing cell, so no row is ever short.
341 ///
342 /// No paging described here either: every one of these tables is a whole
343 /// set the handler already counted.
344 ///
345 /// The revenue cell is the number alone. The template drew a bar behind it
346 /// scaled against the biggest earner, and that is one site in the whole
347 /// template set, so it does not earn a member. See the module header.
348 #[staged]
349 shape comparison(projects: &[ProjectView]) -> Node;
350
351 table {
352 column "Project" {
353 width Fill;
354 priority Essential;
355 }
356 column "Revenue" {
357 width Content;
358 priority Essential;
359 }
360 column "Sales" {
361 width Content;
362 }
363 column "Views" {
364 width Content;
365 }
366 column "Conversion" {
367 width Content;
368 priority Optional;
369 }
370
371 for project in projects.iter() {
372 cells {
373 cell project.title.clone();
374 cell project.revenue.clone();
375 cell project.sales.clone();
376 cell project.views.clone();
377 cell project.conversion.clone();
378 }
379 }
380 }
381 }
382
383 declare! {
384 /// All-time revenue per project.
385 #[staged]
386 shape totals(totals: &[TotalView]) -> Node;
387
388 list {
389 for total in totals.iter() {
390 row total.title.clone() {
391 meta total.revenue.clone();
392 }
393 }
394 }
395 }
396
397 /// One reading of the tab, as the tests draw it.
398 ///
399 /// `pub(crate)` so `residuals` can fill the compiled template against the
400 /// renderer at every shape this screen takes; `ssh_keys::sample_key` is the
401 /// same arrangement for the same reason.
402 ///
403 /// `deltas` picks what the stat cards report, because the three figures are
404 /// three guarded members -- one per tone -- and a fill that only ever saw one
405 /// of them would pass on a residual that had baked it.
406 #[cfg(test)]
407 pub(crate) fn sample(
408 bars: usize,
409 projects: usize,
410 totals: usize,
411 deltas: &[Option<bool>],
412 ) -> Analytics {
413 let cents: Vec<usize> = (0..bars).map(|n| (n + 1) * 137).collect();
414 Analytics {
415 range: "30d".into(),
416 stats: deltas
417 .iter()
418 .enumerate()
419 .map(|(n, delta)| StatView {
420 label: format!("Stat {n}"),
421 value: format!("{n}00"),
422 change: delta.map(|up| if up { "+1.0%".into() } else { "-1.0%".into() }),
423 positive: delta.unwrap_or(true),
424 })
425 .collect(),
426 chart: crate::types::RevenueChart {
427 most: cents.iter().copied().max().unwrap_or(1).max(1),
428 bars: cents
429 .iter()
430 .enumerate()
431 .map(|(n, value)| crate::types::ChartBar {
432 label: format!("Aug {}", n + 1),
433 cents: *value,
434 value: crate::formatting::format_revenue(
435 i64::try_from(*value).unwrap_or(i64::MAX),
436 crate::currency::SettlementCurrency::default(),
437 ),
438 count: n as i64,
439 })
440 .collect(),
441 },
442 projects: (0..projects)
443 .map(|n| ProjectView {
444 title: format!("Project {n}"),
445 revenue: format!("${n}0.00"),
446 sales: n.to_string(),
447 views: format!("{n}00"),
448 conversion: format!("{n}.1%"),
449 })
450 .collect(),
451 totals: (0..totals)
452 .map(|n| TotalView {
453 title: format!("Project {n}"),
454 revenue: format!("${n}80.00"),
455 })
456 .collect(),
457 }
458 }
459
460 /// What a bar's sale count says, worded.
461 ///
462 /// The description carries this already worded rather than carrying the number
463 /// and a noun, because the noun inflects with the count and a renderer that
464 /// pluralised would be growing a lexer for one language. `makeover_layout::Bar`
465 /// says the same thing from the other side.
466 /// The renderer this screen is drawn with.
467 ///
468 /// The plain one every other converted screen uses. It carried a loop over the
469 /// handler's drawn markup while the chart was a ceded region; the chart is
470 /// described now, so there is nothing bespoke left to mount.
471 pub fn renderer(viewer: &Viewer) -> Webview {
472 Webview::new().with_shell(viewer.shell())
473 }
474
475 /// What a bar's sale count says, worded.
476 pub(super) fn sales(count: i64) -> String {
477 if count == 1 {
478 "1 sale".to_string()
479 } else {
480 format!("{count} sales")
481 }
482 }
483
484 #[cfg(test)]
485 mod tests {
486 use super::*;
487 use quasi_axum::Serves;
488 use quasi_router::{Node, RegionKind, Slot};
489
490 fn analytics() -> Analytics {
491 Analytics {
492 range: "30d".into(),
493 stats: vec![
494 StatView {
495 label: "Views".into(),
496 value: "1,204".into(),
497 change: Some("+12.5%".into()),
498 positive: true,
499 },
500 StatView {
501 label: "Conversion".into(),
502 value: "3.1%".into(),
503 change: None,
504 positive: true,
505 },
506 ],
507 chart: crate::types::RevenueChart {
508 most: 6720,
509 bars: vec![crate::types::ChartBar {
510 label: "Aug 1".into(),
511 value: "$42.00".into(),
512 count: 3,
513 cents: 4200,
514 }],
515 },
516 projects: vec![
517 ProjectView {
518 title: "Atlas".into(),
519 revenue: "$120.00".into(),
520 sales: "4".into(),
521 views: "300".into(),
522 conversion: "1.3%".into(),
523 },
524 ProjectView {
525 title: "Beacon".into(),
526 revenue: "$60.00".into(),
527 sales: "2".into(),
528 views: "150".into(),
529 conversion: "1.3%".into(),
530 },
531 ],
532 totals: vec![TotalView {
533 title: "Atlas".into(),
534 revenue: "$980.00".into(),
535 }],
536 }
537 }
538
539 fn render(node: &Node) -> String {
540 Webview::new().fragment(node)
541 }
542
543 // `the_region_matches_what_the_tab_nav_targets` was here until 2026-08-26.
544 // It read `templates/partials/tabs/user_analytics.html` with `include_str!`
545 // and asserted the Askama nav's `hx-target` and `hx-get` agreed with this
546 // module's `REGION` and `PATH`. That was a drift guard between two
547 // renderings of one screen, and it had a subject only while both existed.
548 // `64b33b26` deleted the flag and the Askama rendering with it, so there is
549 // nothing left for the description to disagree with.
550
551 #[test]
552 fn exactly_one_range_is_held_down_and_each_carries_its_own() {
553 let html = render(&Node::Region(
554 range_chips("90d")
555 .into_iter()
556 .fold(Slot::new(REGION, RegionKind::Pane), Slot::with),
557 ));
558
559 assert_eq!(html.matches("latched").count(), 1, "{html}");
560 for range in ["7d", "30d", "90d", "all"] {
561 assert!(
562 html.contains(&format!("range={range}")),
563 "{range} is offered: {html}"
564 );
565 }
566 // An unknown range falls back rather than leaving nothing selected, so
567 // the heading and the selector cannot disagree about where the reader is.
568 assert_eq!(super::super::range_heading("nonsense"), "All time");
569 assert_eq!(super::super::range_heading("7d"), "Last 7 days");
570 }
571
572 #[test]
573 fn a_delta_is_toned_and_a_card_without_one_is_not() {
574 // makeover-layout 0.13.0's whole point. `positive` is true by default in
575 // the source data, so toning on it alone would paint every card that has
576 // nothing to report.
577 let html = render(&stats(&analytics().stats));
578
579 assert!(html.contains("+12.5%"), "{html}");
580 assert_eq!(
581 html.matches("data-tone").count(),
582 1,
583 "one card is toned: {html}"
584 );
585 assert!(html.contains("data-tone=\"success\""), "{html}");
586 assert!(
587 html.contains("3.1%"),
588 "the untoned card is still there: {html}"
589 );
590 }
591
592 #[test]
593 fn the_chart_hands_over_both_numbers_and_computes_no_width() {
594 // The property the described chart exists for. A width worked out here
595 // would be baked into the compiled template as one request's constant,
596 // so the axis and each magnitude have to reach the markup as
597 // themselves. `quasi_router::stage::number_at` is where this is
598 // enforced from the other side.
599 let html = render(&pane(&analytics()));
600
601 assert!(html.contains("--most: 6720"), "{html}");
602 assert!(html.contains("--value: 4200"), "{html}");
603 assert!(!html.contains("--fill"), "{html}");
604 assert!(html.contains("3 sales"), "{html}");
605 }
606
607 #[test]
608 fn a_count_of_one_is_worded_as_one() {
609 // The pluralisation the Askama template does with an `{% if %}`, which
610 // is the description's job here: a renderer that inflected a noun would
611 // be growing a lexer for one language.
612 assert_eq!(sales(1), "1 sale");
613 assert_eq!(sales(0), "0 sales");
614 assert_eq!(sales(3), "3 sales");
615 }
616
617 #[test]
618 fn a_label_and_a_reading_are_escaped_by_the_renderer() {
619 // What the ceded region made this screen do for itself. The chart is a
620 // described member now, so the renderer escapes it like every other
621 // value, and this is the test that the conversion did not quietly drop
622 // the protection along with the bespoke markup.
623 let mut hostile = analytics();
624 hostile.chart.bars[0].label = "<script>x()</script>".into();
625 hostile.chart.bars[0].value = "\" onload=\"x()".into();
626 let html = render(&pane(&hostile));
627
628 assert!(!html.contains("<script>x()"), "{html}");
629 assert!(!html.contains("\" onload="), "{html}");
630 }
631
632 #[test]
633 fn the_comparison_only_draws_when_there_is_something_to_compare() {
634 let mut one = analytics();
635 one.projects.truncate(1);
636 let html = render(&pane(&one));
637 assert!(!html.contains("Project Comparison"), "{html}");
638
639 let both = render(&pane(&analytics()));
640 assert!(both.contains("Project Comparison"), "{both}");
641 assert!(both.contains("Atlas") && both.contains("Beacon"), "{both}");
642 }
643
644 #[test]
645 fn an_account_with_no_history_says_so_in_both_places() {
646 let empty = Analytics {
647 range: "30d".into(),
648 stats: vec![],
649 chart: crate::types::RevenueChart {
650 most: 1,
651 bars: vec![],
652 },
653 projects: vec![],
654 totals: vec![],
655 };
656 let html = render(&pane(&empty));
657
658 assert!(html.contains("revenue data will appear here."), "{html}");
659 assert!(html.contains("Sales across your projects"), "{html}");
660 // And the chart is absent rather than empty, so the stand-in is not
661 // sitting next to a blank chart.
662 assert!(!html.contains("chart-bars"), "{html}");
663 }
664 }
665