//! The user-level analytics tab, described. //! //! S4's third batch, and the first screen that is not a table with a heading on //! it. Five figures with deltas, a range selector, a bar chart, a comparison //! table and a list, which is why it was taken: it is where the vocabulary //! stops covering the dashboard, and the only way to find that out is to //! convert one. //! //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_analytics`, //! which answers the same address from Askama when the screen is switched off. //! //! # What it found, and what each answer cost //! //! Three gaps, and the count decided all three differently. That is the method //! working rather than three separate judgement calls. //! //! 1. **A figure had no delta.** Four screens here put a label, a value and a //! change in one stat card, and the change is the toned part while the //! number is an ordinary fact, so `Figure::tone` had no consumer at all. Four //! sites is below the 30-to-53 the earlier table members cleared, and it //! landed anyway because the alternative was folding the delta into the //! caption, which loses the tone and turns a small second line into a longer //! first one. makeover-layout 0.13.0, makeover-webview 0.24.0. //! 2. **A bar chart is not describable, and should not be.** makeover-layout //! names no chart, and the admission test is that a node composes something //! it already names. Inventing one there would be the layer naming a widget. //! So the chart is a [`quasi_router::RegionKind::Bespoke`] fill, which is //! exactly what bespoke regions are for, and it is the first one in the //! tree. See [`chart_markup`]. //! 3. **A proportion bar inside a table cell is one site.** The comparison //! table's revenue cell draws a bar behind the number. One site across every //! template, so it does not earn a `Cell::meter` and the cell is the number //! alone. **That is a visible loss** and it is recorded rather than hidden: //! the reader keeps every figure and loses the at-a-glance comparison //! between rows. If a second site appears, the member is earned and this //! comes back. //! //! # The range selector is four chips, not a segmented control //! //! Nothing names a segmented control and it does not need to: four chips, one //! latched, is what the affordance is, and `latched` is already makeover's word //! for a held-down chip. Each carries the range it selects, so the address a //! reader lands on is the view they are looking at. use std::fmt::Write as _; use makeover_layout as layout; use quasi_router::screen::{Cell, Cells, Column, Figure, Row, Tag}; use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; use quasi_webview::Webview; use super::Viewer; use crate::db; /// The conversion switch's name for this screen. `QUASI_SCREENS=user_analytics`. pub const SCREEN: &str = "user_analytics"; /// The address this screen answers, and the one the Askama route gives up. pub const PATH: &str = "/dashboard/tabs/analytics"; /// The region the answer replaces: this screen's own frame in the dashboard's /// described tab strip. /// /// It said `tab-content`, the single pane the hand-written strip swapped into. /// Under `super::user_tabs` that id is the strip itself and each panel is its /// own frame, so this moved to the one that is this screen's -- the same move /// `ssh_keys::REGION` made in step 4. `user_tabs` draws its frame from this /// constant and a test there asserts the two agree. pub const REGION: &str = "user-analytics"; /// The slot the chart's own markup mounts into. const CHART_SLOT: &str = "analytics-chart"; /// The ranges offered, in the order the selector draws them. const RANGES: [(&str, &str); 4] = [ ("7d", "Last 7 days"), ("30d", "Last 30 days"), ("90d", "Last 90 days"), ("all", "All time"), ]; /// One stat card, as the screen needs it. pub struct StatView { label: String, value: String, change: Option, positive: bool, } /// One bar of the revenue chart. pub struct BarView { label: String, value: String, count: i64, height_pct: f64, } /// One project's row in the comparison table. pub struct ProjectView { title: String, revenue: String, sales: String, views: String, conversion: String, } /// One project's line in the all-time revenue list. pub struct TotalView { title: String, revenue: String, } /// Everything one answer needs, so the assembly can be tested without a /// database. pub struct Analytics { range: String, stats: Vec, bars: Vec, projects: Vec, totals: Vec, } /// The tab. pub fn screen(viewer: &Viewer, request: Request) -> Result { // The selector's chips carry the range they select, and a read's values // land in `carried`. An unknown or absent one falls back the way the Askama // handler's `parse().ok().unwrap_or` does rather than refusing: a range is // a view, and a bad view is not an error worth a page. // Taken by value because the handler signature is quasi's, so the request is // consumed here rather than borrowed from. let carried = request.carried; let range = carried .get("range") .and_then(|r| r.parse::().ok()) .unwrap_or(db::analytics::TimeRange::Days30); let analytics = read(viewer, &range)?; // Handed to the renderer through the state both of them see. The chart is // the app's own markup and there is no node for it; see the module header. viewer.fill(CHART_SLOT, chart_markup(&analytics.bars)); Ok(Response::fragment(REGION, pane(&analytics))) } /// Everything the screen reads, in the order the Askama handler reads it. fn read(viewer: &Viewer, range: &db::analytics::TimeRange) -> Result { let db = &viewer.app.db; let user_id = viewer.user.id; let currency = viewer.user.settlement_currency; let failed = |_| RouteError::internal("your analytics could not be read"); let buckets = viewer .block_on(db::analytics::get_revenue_timeseries( db, user_id, None, None, range, )) .map_err(failed)?; let comparison = viewer .block_on(db::analytics::get_period_comparison( db, user_id, None, None, range, )) .map_err(failed)?; let (current_views, prev_views) = viewer .block_on(db::page_views::get_view_period_comparison( db, user_id, None, range, )) .map_err(failed)?; let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency) .into_iter() .map(|bar| BarView { label: bar.label, value: bar.value, count: bar.count, height_pct: bar.height_pct, }) .collect(); let view_change = db::analytics::pct_change(current_views, prev_views); let mut stats = vec![ StatView { label: "Views".into(), value: current_views.to_string(), change: view_change.as_ref().map(|(text, _)| text.clone()), positive: view_change.is_none_or(|(_, up)| up), }, StatView { label: "Revenue".into(), value: crate::formatting::format_revenue( comparison.current_revenue_cents.as_i64(), currency, ), change: comparison.revenue_change().map(|(text, _)| text), positive: comparison.revenue_change().is_none_or(|(_, up)| up), }, StatView { label: "Sales".into(), value: comparison.current_sales.to_string(), change: comparison.sales_change().map(|(text, _)| text), positive: comparison.sales_change().is_none_or(|(_, up)| up), }, StatView { label: "Followers".into(), value: comparison.current_followers.to_string(), change: comparison.followers_change().map(|(text, _)| text), positive: comparison.followers_change().is_none_or(|(_, up)| up), }, ]; // Conversion needs a denominator. The Askama version omits the card rather // than showing a dash where a percentage goes. if current_views > 0 { stats.push(StatView { label: "Conversion".into(), value: format!( "{:.1}%", comparison.current_sales as f64 / current_views as f64 * 100.0 ), change: None, positive: true, }); } let project_data = viewer .block_on(db::transactions::get_revenue_by_user_projects_in_range( db, user_id, range, )) .map_err(failed)?; let project_views = viewer .block_on(db::page_views::get_views_by_seller_projects( db, user_id, range, )) .map_err(failed)?; let projects = project_data .iter() .map(|(id, title, revenue, sales)| { let views = project_views .iter() .find(|(other, _)| other == id) .map_or(0, |(_, seen)| *seen); ProjectView { title: title.clone(), revenue: revenue.display(currency), sales: sales.to_string(), views: views.to_string(), conversion: if views > 0 { format!("{:.1}%", *sales as f64 / views as f64 * 100.0) } else { "-".to_owned() }, } }) .collect(); let totals = viewer .block_on(db::transactions::get_revenue_by_user_projects(db, user_id)) .map_err(failed)? .into_iter() .map(|(_, title, revenue)| TotalView { title, revenue: revenue.display(currency), }) .collect(); Ok(Analytics { range: range.to_string(), stats, bars, projects, totals, }) } /// Everything inside the tab pane. fn pane(analytics: &Analytics) -> Node { let mut slot = Slot::new(REGION, RegionKind::Pane).with(Node::section(range_heading(&analytics.range))); for chip in range_chips(&analytics.range) { slot = slot.with(chip); } slot = slot.with(stats(&analytics.stats)); slot = slot.with(Node::section("Revenue Over Time")); slot = if analytics.bars.is_empty() { slot.with(Node::empty( "Once you publish items and make sales, revenue data will appear here.", )) } else { // The chart's own markup arrives through the renderer. The description // says only that there is a region here and what it is called. slot.with(Node::Region(Slot::bespoke(CHART_SLOT, "revenue-chart"))) }; // One project is not a comparison, which is the condition the template // wraps this whole section in. if analytics.projects.len() > 1 { slot = slot .with(Node::section("Project Comparison")) .with(comparison(&analytics.projects)); } slot = slot.with(Node::section("Top Projects by Revenue")); slot = if analytics.totals.is_empty() { slot.with(Node::empty( "No revenue data yet. Sales across your projects will appear here.", )) } else { slot.with(totals(&analytics.totals)) }; Node::Region(slot) } /// What the current range is called. fn range_heading(range: &str) -> &'static str { RANGES .iter() .find(|(value, _)| *value == range) .map_or("All time", |(_, name)| *name) } /// The range selector: one chip per range, the current one held down. fn range_chips(range: &str) -> Vec { RANGES .iter() .map(|(value, _)| { Node::Token( Tag::chip(*value, Action::get(PATH).carrying("range", *value)) .latched(*value == range), ) }) .collect() } /// The figures across the top. fn stats(stats: &[StatView]) -> Node { Node::Stats { figures: stats .iter() .map(|stat| { let mut figure = Figure::new(stat.value.clone(), stat.label.clone()); // The tone rides on the delta, which is why a card without one // stays neutral rather than being coloured green for having // nothing to report. `positive` is `true` by default in the // source data, so toning on it alone would paint every // unchanged card. if let Some(change) = &stat.change { figure = figure.change(change.clone()).tone(if stat.positive { layout::Tone::Success } else { layout::Tone::Danger }); } (figure, None) }) .collect(), } } /// The per-project comparison. fn comparison(projects: &[ProjectView]) -> Node { Node::Table { columns: vec![ Column::new("Project") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Revenue") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("Sales").width(layout::Width::Content), Column::new("Views").width(layout::Width::Content), Column::new("Conversion") .width(layout::Width::Content) .priority(layout::Priority::Optional), ], rows: projects .iter() .map(|project| { Cells::new([ Cell::new(project.title.clone()), // The number alone. The template draws a bar behind it // scaled against the biggest earner, and that is one site // in the whole template set, so it does not earn a member. // See the module header. Cell::new(project.revenue.clone()), Cell::new(project.sales.clone()), Cell::new(project.views.clone()), Cell::new(project.conversion.clone()), ]) }) .collect(), // No paging described here: every one of these tables is a // whole set the handler already counted. more: None, } } /// All-time revenue per project. fn totals(totals: &[TotalView]) -> Node { Node::List { rows: totals .iter() .map(|total| Row::new(total.title.clone()).meta(total.revenue.clone())) .collect(), more: None, } } /// The chart, as markup, because no description names one. /// /// Byte-for-byte the structure `templates/partials/chart_bars.html` emits, so /// the existing `.chart-*` rules in `style.css` draw it unchanged and the /// described screen and the Askama one are the same chart rather than two that /// drifted. /// /// Everything interpolated here is escaped. A bespoke region is not escaped by /// the renderer, which is what makes it bespoke, so the escaping is this /// function's job and a label reaching it from a database is exactly why. fn chart_markup(bars: &[BarView]) -> String { let mut html = String::from("
"); for bar in bars { let plural = if bar.count == 1 { "" } else { "s" }; let _ = write!( html, "
\
\
{}
", escape(&bar.value), bar.count, // A float straight from the database, so it is formatted rather // than printed: `{:?}` on an f64 can emit an exponent, and // `--fill: 1e-7%` is not a length any browser accepts. format_args!("{:.4}", bar.height_pct), escape(&bar.label), ); } html.push_str("
"); html } /// The five characters that matter in markup and in an attribute value. fn escape(text: &str) -> String { text.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// The renderer this screen is drawn with. /// /// Mounts whatever the handler drew for its bespoke regions. The two see one /// `Viewer`; see [`super::Viewer::fills`]. pub fn renderer(viewer: &Viewer) -> Webview { let mut webview = Webview::new().with_shell(viewer.shell()); for (slot, markup) in viewer.drawn() { webview = webview.with_fill(slot, markup); } webview } #[cfg(test)] mod tests { use super::*; use quasi_axum::Serves; fn analytics() -> Analytics { Analytics { range: "30d".into(), stats: vec![ StatView { label: "Views".into(), value: "1,204".into(), change: Some("+12.5%".into()), positive: true, }, StatView { label: "Conversion".into(), value: "3.1%".into(), change: None, positive: true, }, ], bars: vec![BarView { label: "Aug 1".into(), value: "$42.00".into(), count: 3, height_pct: 62.5, }], projects: vec![ ProjectView { title: "Atlas".into(), revenue: "$120.00".into(), sales: "4".into(), views: "300".into(), conversion: "1.3%".into(), }, ProjectView { title: "Beacon".into(), revenue: "$60.00".into(), sales: "2".into(), views: "150".into(), conversion: "1.3%".into(), }, ], totals: vec![TotalView { title: "Atlas".into(), revenue: "$980.00".into(), }], } } fn render(node: &Node) -> String { Webview::new().fragment(node) } #[test] fn the_region_matches_what_the_tab_nav_targets() { let nav = include_str!("../../templates/partials/tabs/user_analytics.html"); assert!(nav.contains(&format!("hx-target=\"#{REGION}\""))); assert!(nav.contains(&format!("hx-get=\"{PATH}?range=7d\""))); } #[test] fn exactly_one_range_is_held_down_and_each_carries_its_own() { let html = render(&Node::Region( range_chips("90d") .into_iter() .fold(Slot::new(REGION, RegionKind::Pane), Slot::with), )); assert_eq!(html.matches("latched").count(), 1, "{html}"); for range in ["7d", "30d", "90d", "all"] { assert!( html.contains(&format!("range={range}")), "{range} is offered: {html}" ); } // An unknown range falls back rather than leaving nothing selected, so // the heading and the selector cannot disagree about where the reader is. assert_eq!(range_heading("nonsense"), "All time"); assert_eq!(range_heading("7d"), "Last 7 days"); } #[test] fn a_delta_is_toned_and_a_card_without_one_is_not() { // makeover-layout 0.13.0's whole point. `positive` is true by default in // the source data, so toning on it alone would paint every card that has // nothing to report. let html = render(&stats(&analytics().stats)); assert!(html.contains("+12.5%"), "{html}"); assert_eq!( html.matches("data-tone").count(), 1, "one card is toned: {html}" ); assert!(html.contains("data-tone=\"success\""), "{html}"); assert!( html.contains("3.1%"), "the untoned card is still there: {html}" ); } #[test] fn the_chart_is_the_markup_the_template_already_emits() { // The described screen and the Askama one draw one chart, against one // set of `.chart-*` rules. If this structure drifts the two diverge // silently, because nothing else renders it. let html = chart_markup(&analytics().bars); assert!(html.contains("class=\"chart-bars\""), "{html}"); assert!(html.contains("class=\"chart-bar-col\""), "{html}"); assert!(html.contains("--fill: 62.5000%"), "{html}"); assert!(html.contains("3 sales"), "{html}"); // The template's own pluralisation, which a described copy is easy to // get wrong in exactly one direction. let one = chart_markup(&[BarView { label: "Aug 2".into(), count: 1, ..analytics().bars.pop().expect("one bar") }]); assert!(one.contains("1 sale ") || one.contains("1 sale\""), "{one}"); } #[test] fn a_bespoke_fill_is_the_apps_markup_and_still_escapes_its_data() { // A bespoke region is not escaped by the renderer, which is the whole // of what makes it bespoke. A bar's label is a formatted date today and // its value comes from the database, so the escaping is this screen's // job and nothing else will do it. let html = chart_markup(&[BarView { label: "".into(), value: "\" onload=\"x()".into(), count: 1, height_pct: 10.0, }]); assert!(!html.contains("