//! 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 describable, and this screen is why.** It was not, for a //! while: makeover-layout named no chart, and the admission test is that a //! node composes something it already names, so the chart was a //! `RegionKind::Ceded` fill -- the first and last one in the tree. That put //! this screen alone off the compiled-template seam, because a ceded //! region's markup is looked up WHILE the renderer renders and a residual //! has nowhere to keep it. Max ruled on 2026-09-08 that a bespoke region is //! the mark of a screen the description layer has not finished converting, //! so the chart was described instead: `makeover_layout::Chart` and `Bar`, //! drawn by each renderer. quasicoherent `7d6ad166`. //! 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 makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::{Bar, Chart, Figure}; use quasi_router::{Request, Response, RouteError}; use quasi_webview::Webview; use super::Viewer; use crate::db; /// This screen's name. Was the `QUASI_SCREENS` switch name until `64b33b26` /// deleted the flag; it survives as the marker the tab strips read. 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"; /// One stat card, as the screen needs it. pub struct StatView { label: String, value: String, change: Option, positive: bool, } /// 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, chart: crate::types::RevenueChart, 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)?; 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.reader()?.id; let currency = viewer.reader()?.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)?; // `build_revenue_chart` already produces exactly what the chart draws. This // remapped it into a private `BarView` with the same fields, which meant // `project_analytics` could not reuse the chart without a third copy of the // type. Dropped 2026-08-26 when that screen converted. let chart = crate::routes::pages::dashboard::build_revenue_chart(&buckets, currency); 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, chart, projects, totals, }) } declare! { /// Everything inside the tab pane. /// /// One project is not a comparison, which is why that section carries the /// same guard twice: the heading and the table are two members and both are /// absent together. #[staged] pub(crate) shape pane(analytics: &Analytics) -> Node; region REGION as Pane { section super::range_heading(&analytics.range); include each range_chips(&analytics.range); include stats(&analytics.stats); section "Revenue Over Time"; empty "Once you publish items and make sales, revenue data will appear here." when analytics.chart.bars.is_empty(); chart Chart::new(analytics.chart.most).label("revenue over time") unless analytics.chart.bars.is_empty() { for bar in analytics.chart.bars.iter() { bar Bar::at(bar.label.clone()) .of(bar.cents) .reading(bar.value.clone()) .note(sales(bar.count)); } } section "Project Comparison" when analytics.projects.len() over 1; include comparison(&analytics.projects) when analytics.projects.len() over 1; section "Top Projects by Revenue"; empty "No revenue data yet. Sales across your projects will appear here." when analytics.totals.is_empty(); include totals(&analytics.totals) unless analytics.totals.is_empty(); } } declare! { /// The range selector: one chip per range, the current one held down. #[staged] shape range_chips(range: &str) -> Vec; for window in super::RANGES { chip window.value to get PATH carrying "range" window.value { latched when super::is_shown(window, range); } } } /// The delta a card reports, or nothing. fn change(stat: &StatView) -> &str { stat.change.as_deref().unwrap_or_default() } declare! { /// The figures across the top. /// /// The empty list is what the figures accrete onto: `Node::stats` takes the /// whole list and this one is built a card at a time. #[staged] shape stats(stats: &[StatView]) -> Node; stats [] { for stat in stats.iter() { // Three, one per tone a delta can carry, because a tone is not a // value a residual can hold: `Tone` has no stand-in, so a supplier // answering one hands the derivation a sentinel where an enum // belongs. Written out, each tone is a path the derivation bakes and // the guards are what a request picks between. `symbolic::PLACED` // names this site. figure Figure::new(stat.value.clone(), stat.label.clone()) when stat.change.is_none(); figure Figure::new(stat.value.clone(), stat.label.clone()) .change(change(stat)) .tone(layout::Tone::Success) when stat.change.is_some() and stat.positive; figure Figure::new(stat.value.clone(), stat.label.clone()) .change(change(stat)) .tone(layout::Tone::Danger) when stat.change.is_some() and not stat.positive; } } } declare! { /// The per-project comparison. /// /// The cells are positional because the column list is a few lines above /// them and every project fills all five: an empty conversion is the string /// "-" rather than a missing cell, so no row is ever short. /// /// No paging described here either: every one of these tables is a whole /// set the handler already counted. /// /// The revenue cell is the number alone. The template drew 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. #[staged] shape comparison(projects: &[ProjectView]) -> Node; table { column "Project" { width Fill; priority Essential; } column "Revenue" { width Content; priority Essential; } column "Sales" { width Content; } column "Views" { width Content; } column "Conversion" { width Content; priority Optional; } for project in projects.iter() { cells { cell project.title.clone(); cell project.revenue.clone(); cell project.sales.clone(); cell project.views.clone(); cell project.conversion.clone(); } } } } declare! { /// All-time revenue per project. #[staged] shape totals(totals: &[TotalView]) -> Node; list { for total in totals.iter() { row total.title.clone() { meta total.revenue.clone(); } } } } /// One reading of the tab, as the tests draw it. /// /// `pub(crate)` so `residuals` can fill the compiled template against the /// renderer at every shape this screen takes; `ssh_keys::sample_key` is the /// same arrangement for the same reason. /// /// `deltas` picks what the stat cards report, because the three figures are /// three guarded members -- one per tone -- and a fill that only ever saw one /// of them would pass on a residual that had baked it. #[cfg(test)] pub(crate) fn sample( bars: usize, projects: usize, totals: usize, deltas: &[Option], ) -> Analytics { let cents: Vec = (0..bars).map(|n| (n + 1) * 137).collect(); Analytics { range: "30d".into(), stats: deltas .iter() .enumerate() .map(|(n, delta)| StatView { label: format!("Stat {n}"), value: format!("{n}00"), change: delta.map(|up| if up { "+1.0%".into() } else { "-1.0%".into() }), positive: delta.unwrap_or(true), }) .collect(), chart: crate::types::RevenueChart { most: cents.iter().copied().max().unwrap_or(1).max(1), bars: cents .iter() .enumerate() .map(|(n, value)| crate::types::ChartBar { label: format!("Aug {}", n + 1), cents: *value, value: crate::formatting::format_revenue( i64::try_from(*value).unwrap_or(i64::MAX), crate::currency::SettlementCurrency::default(), ), count: n as i64, }) .collect(), }, projects: (0..projects) .map(|n| ProjectView { title: format!("Project {n}"), revenue: format!("${n}0.00"), sales: n.to_string(), views: format!("{n}00"), conversion: format!("{n}.1%"), }) .collect(), totals: (0..totals) .map(|n| TotalView { title: format!("Project {n}"), revenue: format!("${n}80.00"), }) .collect(), } } /// What a bar's sale count says, worded. /// /// The description carries this already worded rather than carrying the number /// and a noun, because the noun inflects with the count and a renderer that /// pluralised would be growing a lexer for one language. `makeover_layout::Bar` /// says the same thing from the other side. /// The renderer this screen is drawn with. /// /// The plain one every other converted screen uses. It carried a loop over the /// handler's drawn markup while the chart was a ceded region; the chart is /// described now, so there is nothing bespoke left to mount. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } /// What a bar's sale count says, worded. pub(super) fn sales(count: i64) -> String { if count == 1 { "1 sale".to_string() } else { format!("{count} sales") } } #[cfg(test)] mod tests { use super::*; use quasi_axum::Serves; use quasi_router::{Node, RegionKind, Slot}; 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, }, ], chart: crate::types::RevenueChart { most: 6720, bars: vec![crate::types::ChartBar { label: "Aug 1".into(), value: "$42.00".into(), count: 3, cents: 4200, }], }, 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) } // `the_region_matches_what_the_tab_nav_targets` was here until 2026-08-26. // It read `templates/partials/tabs/user_analytics.html` with `include_str!` // and asserted the Askama nav's `hx-target` and `hx-get` agreed with this // module's `REGION` and `PATH`. That was a drift guard between two // renderings of one screen, and it had a subject only while both existed. // `64b33b26` deleted the flag and the Askama rendering with it, so there is // nothing left for the description to disagree with. #[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!(super::super::range_heading("nonsense"), "All time"); assert_eq!(super::super::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_hands_over_both_numbers_and_computes_no_width() { // The property the described chart exists for. A width worked out here // would be baked into the compiled template as one request's constant, // so the axis and each magnitude have to reach the markup as // themselves. `quasi_router::stage::number_at` is where this is // enforced from the other side. let html = render(&pane(&analytics())); assert!(html.contains("--most: 6720"), "{html}"); assert!(html.contains("--value: 4200"), "{html}"); assert!(!html.contains("--fill"), "{html}"); assert!(html.contains("3 sales"), "{html}"); } #[test] fn a_count_of_one_is_worded_as_one() { // The pluralisation the Askama template does with an `{% if %}`, which // is the description's job here: a renderer that inflected a noun would // be growing a lexer for one language. assert_eq!(sales(1), "1 sale"); assert_eq!(sales(0), "0 sales"); assert_eq!(sales(3), "3 sales"); } #[test] fn a_label_and_a_reading_are_escaped_by_the_renderer() { // What the ceded region made this screen do for itself. The chart is a // described member now, so the renderer escapes it like every other // value, and this is the test that the conversion did not quietly drop // the protection along with the bespoke markup. let mut hostile = analytics(); hostile.chart.bars[0].label = "".into(); hostile.chart.bars[0].value = "\" onload=\"x()".into(); let html = render(&pane(&hostile)); assert!(!html.contains("